From df506dfea4aa61f6b86e81a97645dcb007c15019 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 12 Mar 2026 18:22:38 +0100 Subject: [PATCH] refactor: P3 dekompozice velkych komponent Dashboard.jsx (1346 -> 378 LOC): - DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions - dashboardHelpers.js (konstanty + helper funkce) OfferDetail.jsx (1061 -> ~530 LOC): - useOfferForm hook (form state, draft, items/sections, submit) - OfferCustomerPicker (customer search/select dropdown) AttendanceAdmin.jsx (1036 -> ~275 LOC): - useAttendanceAdmin hook (data fetching, filters, CRUD, print) - AttendanceShiftTable (shift records table) Co-Authored-By: Claude Opus 4.6 --- src/admin/components/AttendanceShiftTable.jsx | 146 +++ src/admin/components/OfferCustomerPicker.jsx | 90 ++ .../components/dashboard/DashActivityFeed.jsx | 65 + .../dashboard/DashAttendanceToday.jsx | 33 + .../components/dashboard/DashKpiCards.jsx | 91 ++ .../components/dashboard/DashProfile.jsx | 310 +++++ .../components/dashboard/DashQuickActions.jsx | 335 +++++ .../components/dashboard/DashSessions.jsx | 199 +++ src/admin/hooks/useAttendanceAdmin.js | 627 +++++++++ src/admin/hooks/useOfferForm.js | 437 +++++++ src/admin/pages/AttendanceAdmin.jsx | 791 +----------- src/admin/pages/Dashboard.jsx | 1118 ++--------------- src/admin/pages/OfferDetail.jsx | 528 +------- src/admin/utils/dashboardHelpers.js | 85 ++ 14 files changed, 2558 insertions(+), 2297 deletions(-) create mode 100644 src/admin/components/AttendanceShiftTable.jsx create mode 100644 src/admin/components/OfferCustomerPicker.jsx create mode 100644 src/admin/components/dashboard/DashActivityFeed.jsx create mode 100644 src/admin/components/dashboard/DashAttendanceToday.jsx create mode 100644 src/admin/components/dashboard/DashKpiCards.jsx create mode 100644 src/admin/components/dashboard/DashProfile.jsx create mode 100644 src/admin/components/dashboard/DashQuickActions.jsx create mode 100644 src/admin/components/dashboard/DashSessions.jsx create mode 100644 src/admin/hooks/useAttendanceAdmin.js create mode 100644 src/admin/hooks/useOfferForm.js create mode 100644 src/admin/utils/dashboardHelpers.js diff --git a/src/admin/components/AttendanceShiftTable.jsx b/src/admin/components/AttendanceShiftTable.jsx new file mode 100644 index 0000000..f01377e --- /dev/null +++ b/src/admin/components/AttendanceShiftTable.jsx @@ -0,0 +1,146 @@ +import { Link } from 'react-router-dom' +import { + formatDate, formatDatetime, formatTime, + calculateWorkMinutes, formatMinutes, + getLeaveTypeName, getLeaveTypeBadgeClass +} from '../utils/attendanceHelpers' + +function formatBreak(record) { + if (record.break_start && record.break_end) { + return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}` + } + if (record.break_start) { + return `${formatTime(record.break_start)} - ?` + } + return '—' +} + +function renderProjectCell(record) { + if (record.project_logs && record.project_logs.length > 0) { + return ( +
+ {record.project_logs.map((log, i) => { + let h, m, isActive = false + if (log.hours !== null && log.hours !== undefined) { + h = parseInt(log.hours) || 0 + m = parseInt(log.minutes) || 0 + } else { + isActive = !log.ended_at + const end = log.ended_at ? new Date(log.ended_at) : new Date() + const mins = Math.floor((end - new Date(log.started_at)) / 60000) + h = Math.floor(mins / 60) + m = mins % 60 + } + return ( + + {log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''}) + + ) + })} +
+ ) + } + if (record.project_name) { + return {record.project_name} + } + return '—' +} + +export default function AttendanceShiftTable({ records, onEdit, onDelete }) { + if (records.length === 0) { + return ( +
+

Za tento měsíc nejsou žádné záznamy.

+
+ ) + } + + return ( +
+ + + + + + + + + + + + + + + + + + {records.map((record) => { + const leaveType = record.leave_type || 'work' + const isLeave = leaveType !== 'work' + const workMinutes = isLeave + ? (record.leave_hours || 8) * 60 + : calculateWorkMinutes(record) + const hasLocation = (record.arrival_lat && record.arrival_lng) || (record.departure_lat && record.departure_lng) + + return ( + + + + + + + + + + + + + + ) + })} + +
DatumZaměstnanecTypPříchodPauzaOdchodHodinyProjektGPSPoznámkaAkce
{formatDate(record.shift_date)}{record.user_name} + + {getLeaveTypeName(leaveType)} + + {isLeave ? '—' : formatDatetime(record.arrival_time)} + {isLeave ? '—' : formatBreak(record)} + {isLeave ? '—' : formatDatetime(record.departure_time)}{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : '—'} + {renderProjectCell(record)} + + {hasLocation ? ( + + 📍 + + ) : '—'} + + {record.notes || ''} + +
+ + +
+
+
+ ) +} diff --git a/src/admin/components/OfferCustomerPicker.jsx b/src/admin/components/OfferCustomerPicker.jsx new file mode 100644 index 0000000..4d07510 --- /dev/null +++ b/src/admin/components/OfferCustomerPicker.jsx @@ -0,0 +1,90 @@ +import { useState, useEffect, useMemo } from 'react' + +export default function OfferCustomerPicker({ + customers, + customerId, + customerName, + onSelect, + onClear, + error, + readOnly +}) { + const [customerSearch, setCustomerSearch] = useState('') + const [showDropdown, setShowDropdown] = useState(false) + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = () => setShowDropdown(false) + if (showDropdown) { + document.addEventListener('click', handleClickOutside) + return () => document.removeEventListener('click', handleClickOutside) + } + }, [showDropdown]) + + const filteredCustomers = useMemo(() => { + if (!customerSearch) return customers + const q = customerSearch.toLowerCase() + return customers.filter(c => + (c.name || '').toLowerCase().includes(q) || + (c.company_id || '').includes(customerSearch) || + (c.city || '').toLowerCase().includes(q) + ) + }, [customers, customerSearch]) + + const handleSelect = (customer) => { + onSelect(customer) + setCustomerSearch('') + setShowDropdown(false) + } + + return ( +
+ + {customerId && ( +
+ {customerName} + {!readOnly && ( + + )} +
+ )} + {!customerId && !readOnly && ( +
e.stopPropagation()}> + { setCustomerSearch(e.target.value); setShowDropdown(true) }} + onFocus={() => setShowDropdown(true)} + className="admin-form-input" + placeholder="Hledat zákazníka..." + /> + {showDropdown && ( +
+ {filteredCustomers.length === 0 ? ( +
+ Žádní zákazníci +
+ ) : ( + filteredCustomers.slice(0, 10).map(c => ( +
handleSelect(c)} + > +
{c.name}
+ {c.city &&
{c.city}
} +
+ )) + )} +
+ )} +
+ )} + {error && {error}} +
+ ) +} diff --git a/src/admin/components/dashboard/DashActivityFeed.jsx b/src/admin/components/dashboard/DashActivityFeed.jsx new file mode 100644 index 0000000..714c3fa --- /dev/null +++ b/src/admin/components/dashboard/DashActivityFeed.jsx @@ -0,0 +1,65 @@ +import { ENTITY_TYPE_LABELS, getActivityIconClass, formatActivityTime } from '../../utils/dashboardHelpers' + +function getActivityIcon(action) { + switch (action) { + case 'create': + return ( + + + + ) + case 'update': + return ( + + + + + ) + case 'delete': + return ( + + + + ) + case 'login': + return ( + + + + ) + default: + return ( + + + + ) + } +} + +export default function DashActivityFeed({ activities }) { + if (!activities) { + return null + } + + return ( +
+
+

Poslední aktivita

+
+
+ {activities.map((act) => ( +
+
+ {getActivityIcon(act.action)} +
+
+
{act.description}
+
{act.username || 'Systém'} · {ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
+
+
{formatActivityTime(act.created_at)}
+
+ ))} +
+
+ ) +} diff --git a/src/admin/components/dashboard/DashAttendanceToday.jsx b/src/admin/components/dashboard/DashAttendanceToday.jsx new file mode 100644 index 0000000..69dedbe --- /dev/null +++ b/src/admin/components/dashboard/DashAttendanceToday.jsx @@ -0,0 +1,33 @@ +import { Link } from 'react-router-dom' +import { LEAVE_TYPE_LABELS, STATUS_DOT_CLASS, STATUS_LABELS } from '../../utils/dashboardHelpers' + +export default function DashAttendanceToday({ attendance }) { + if (!attendance) { + return null + } + + return ( +
+
+

Docházka dnes

+ Detail → +
+
+ {attendance.users.map((u) => ( +
+
+ {u.initials || '?'} +
+
{u.name}
+
+ + {u.status === 'leave' ? (LEAVE_TYPE_LABELS[u.leave_type] || 'Nepřítomen') : STATUS_LABELS[u.status]} + + {u.arrived_at && {u.arrived_at}} +
+
+ ))} +
+
+ ) +} diff --git a/src/admin/components/dashboard/DashKpiCards.jsx b/src/admin/components/dashboard/DashKpiCards.jsx new file mode 100644 index 0000000..97f8824 --- /dev/null +++ b/src/admin/components/dashboard/DashKpiCards.jsx @@ -0,0 +1,91 @@ +import { motion } from 'framer-motion' +import { formatCurrency } from '../../utils/formatters' + +function buildKpiCards(dashData) { + const cards = [] + if (dashData?.attendance) { + cards.push({ + label: 'Přítomní dnes', + value: `${dashData.attendance.present_today}`, + sub: `/ ${dashData.attendance.total_active}`, + color: 'success', + footer: dashData.attendance.on_leave > 0 ? `${dashData.attendance.on_leave} nepřítomných` : null, + }) + } + if (dashData?.offers) { + cards.push({ + label: 'Otevřené nabídky', + value: `${dashData.offers.open_count}`, + color: 'info', + footer: dashData.offers.created_this_month > 0 ? `${dashData.offers.created_this_month} tento měsíc` : null, + }) + } + if (dashData?.invoices) { + cards.push(buildInvoiceKpi(dashData.invoices)) + } + if (dashData?.leave_pending) { + cards.push({ + label: 'Žádosti o volno', + value: `${dashData.leave_pending.count}`, + color: 'danger', + footer: dashData.leave_pending.count > 0 ? 'čeká na schválení' : null, + }) + } + return cards +} + +function buildInvoiceKpi(invoices) { + const rev = invoices.revenue_this_month || [] + const hasForeign = rev.some(r => r.currency !== 'CZK') + const hasCzkTotal = hasForeign && invoices.revenue_czk !== null && invoices.revenue_czk !== undefined + const fallbackText = rev.length > 0 + ? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ') + : '0 Kč' + const revenueText = hasCzkTotal + ? formatCurrency(invoices.revenue_czk, 'CZK') + : fallbackText + const detailText = hasForeign && rev.length > 0 + ? rev.map(r => formatCurrency(r.amount, r.currency)).join(' · ') + : null + const unpaidText = invoices.unpaid_count > 0 + ? `${invoices.unpaid_count} neuhrazených` + : null + const footerParts = [detailText, unpaidText].filter(Boolean) + return { + label: 'Tržby (měsíc)', + value: revenueText, + color: 'warning', + footer: footerParts.length > 0 ? footerParts.join(' · ') : null, + } +} + +const KPI_CLASS_MAP = { 4: 'dash-kpi-4', 3: 'dash-kpi-3', 2: 'dash-kpi-2', 1: 'dash-kpi-1' } + +export default function DashKpiCards({ dashData }) { + const kpiCards = buildKpiCards(dashData) + if (kpiCards.length === 0) { + return null + } + + const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || 'dash-kpi-4' + + return ( + + {kpiCards.map((kpi) => ( +
+
{kpi.label}
+
+ {kpi.value} + {kpi.sub && {kpi.sub}} +
+ {kpi.footer &&
{kpi.footer}
} +
+ ))} +
+ ) +} diff --git a/src/admin/components/dashboard/DashProfile.jsx b/src/admin/components/dashboard/DashProfile.jsx new file mode 100644 index 0000000..8695d01 --- /dev/null +++ b/src/admin/components/dashboard/DashProfile.jsx @@ -0,0 +1,310 @@ +import { useState, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { useAuth } from '../../context/AuthContext' +import { useAlert } from '../../context/AlertContext' +import useModalLock from '../../hooks/useModalLock' +import apiFetch from '../../utils/api' + +const API_BASE = '/api/admin' + +export default function DashProfile({ + totpEnabled, totpLoading, totpSubmitting, + onStart2FASetup, onConfirm2FA, onDisable2FA, + totpSecret, totpQrUri, totpCode, setTotpCode, + backupCodes, setBackupCodes, + show2FASetup, setShow2FASetup, + show2FADisable, setShow2FADisable, + disableCode, setDisableCode, +}) { + const { user, updateUser } = useAuth() + const alert = useAlert() + const totpSetupRef = useRef(null) + + const [showModal, setShowModal] = useState(false) + const [formData, setFormData] = useState({ + username: '', email: '', password: '', first_name: '', last_name: '' + }) + + useModalLock(showModal) + + const openEditModal = () => { + const nameParts = (user?.fullName || '').split(' ') + setFormData({ + username: user?.username || '', + email: user?.email || '', + password: '', + first_name: nameParts[0] || '', + last_name: nameParts.slice(1).join(' ') || '' + }) + setShowModal(true) + } + + const handleSubmit = async (e) => { + e?.preventDefault() + const dataToSave = { ...formData } + try { + const response = await apiFetch(`${API_BASE}/profile.php`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dataToSave) + }) + const data = await response.json() + if (data.success) { + updateUser({ + username: dataToSave.username, + email: dataToSave.email, + fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim() + }) + setShowModal(false) + await new Promise(resolve => setTimeout(resolve, 300)) + alert.success('Profil byl upraven') + } else { + alert.error(data.error || 'Nepodařilo se uložit profil') + } + } catch { + alert.error('Chyba připojení') + } + } + + function getTotpStatusText() { + if (totpLoading) { + return 'Načítání...' + } + return totpEnabled ? 'Aktivní' : 'Neaktivní' + } + + return ( + <> + +
+

Váš účet

+ +
+
+
+
+ Uživatel + {user?.username} +
+
+ E-mail + {user?.email} +
+
+ Jméno + {user?.fullName} +
+
+ Role + {user?.roleDisplay || user?.role} +
+
+ + {/* 2FA Section */} +
+
+
+
+ + + +
+
+
Dvoufaktorové ověření (2FA)
+
+ {getTotpStatusText()} +
+
+
+ {!totpLoading && ( + totpEnabled ? ( + + ) : ( + + ) + )} +
+
+
+
+ + {/* Edit Profile Modal */} + + {showModal && ( + +
setShowModal(false)} /> + +

Upravit profil

+
+
+
+
+ + setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" /> +
+
+ + setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" /> +
+
+
+ + setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" /> +
+
+ + setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" /> +
+
+ + setFormData({ ...formData, password: e.target.value })} className="admin-form-input" /> +
+
+
+
+ + +
+
+ + )} + + + {/* 2FA Setup Modal */} + + {show2FASetup && ( + +
{ if (!backupCodes) { setShow2FASetup(false) } }} /> + +
+

{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}

+
+
+ {backupCodes ? ( +
+
+ + + + + Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte. +
+
+ {backupCodes.map((code) => ( +
{code}
+ ))} +
+
+ +
+
+ ) : ( +
+

+ Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.) +

+ {totpQrUri && ( +
+ { + if (canvas && totpQrUri) { + import('../../../utils/qrcode.js').then(({ renderQR }) => renderQR(canvas, totpQrUri)) + } + }} + style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }} + /> +
+ )} + {totpSecret && ( +
+ +
+ {totpSecret} + +
+
+ )} +
+ + setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} /> +
+
+ )} +
+
+ {backupCodes ? ( + + ) : ( + <> + + + + )} +
+
+ + )} + + + {/* 2FA Disable Modal */} + + {show2FADisable && ( + +
setShow2FADisable(false)} /> + +

Deaktivovat 2FA

+
+

+ Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace. +

+
+ + setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus /> +
+
+
+ + +
+
+ + )} + + + ) +} diff --git a/src/admin/components/dashboard/DashQuickActions.jsx b/src/admin/components/dashboard/DashQuickActions.jsx new file mode 100644 index 0000000..8fe80aa --- /dev/null +++ b/src/admin/components/dashboard/DashQuickActions.jsx @@ -0,0 +1,335 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { useAuth } from '../../context/AuthContext' +import { useAlert } from '../../context/AlertContext' +import { formatKm } from '../../utils/formatters' +import AdminDatePicker from '../AdminDatePicker' +import apiFetch from '../../utils/api' +import useModalLock from '../../hooks/useModalLock' + +const API_BASE = '/api/admin' + +export default function DashQuickActions({ dashData, punching, onPunch }) { + const { hasPermission } = useAuth() + const alert = useAlert() + + const [showTripModal, setShowTripModal] = useState(false) + const [tripSubmitting, setTripSubmitting] = useState(false) + const [tripVehicles, setTripVehicles] = useState([]) + const [tripForm, setTripForm] = useState({ + vehicle_id: '', trip_date: '', start_km: '', end_km: '', + route_from: '', route_to: '', is_business: 1, notes: '' + }) + const [tripErrors, setTripErrors] = useState({}) + + useModalLock(showTripModal) + + const openTripModal = async () => { + setTripForm({ + vehicle_id: '', trip_date: new Date().toISOString().split('T')[0], + start_km: '', end_km: '', route_from: '', route_to: '', + is_business: 1, notes: '' + }) + setTripErrors({}) + setShowTripModal(true) + + try { + const response = await apiFetch(`${API_BASE}/trips.php?action=active_vehicles`) + const result = await response.json() + if (result.success) { + setTripVehicles(result.data.vehicles || []) + } + } catch { + // vozidla se nenacetla + } + } + + const handleTripVehicleChange = async (vehicleId) => { + setTripForm(prev => ({ ...prev, vehicle_id: vehicleId })) + if (!vehicleId) { + return + } + try { + const response = await apiFetch(`${API_BASE}/trips.php?action=last_km&vehicle_id=${vehicleId}`) + const result = await response.json() + if (result.success) { + setTripForm(prev => ({ ...prev, start_km: result.data.last_km })) + } + } catch { + // last_km se nenacetlo + } + } + + const handleTripSubmit = async () => { + const errs = {} + if (!tripForm.vehicle_id) { + errs.vehicle_id = 'Vyberte vozidlo' + } + if (!tripForm.trip_date) { + errs.trip_date = 'Zadejte datum' + } + if (!tripForm.start_km) { + errs.start_km = 'Zadejte počáteční km' + } + if (!tripForm.end_km) { + errs.end_km = 'Zadejte konečný km' + } + if (tripForm.start_km && tripForm.end_km && parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)) { + errs.end_km = 'Musí být větší než počáteční' + } + if (!tripForm.route_from) { + errs.route_from = 'Zadejte místo odjezdu' + } + if (!tripForm.route_to) { + errs.route_to = 'Zadejte místo příjezdu' + } + setTripErrors(errs) + if (Object.keys(errs).length > 0) { + return + } + + setTripSubmitting(true) + try { + const response = await apiFetch(`${API_BASE}/trips.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tripForm) + }) + const result = await response.json() + if (result.success) { + setShowTripModal(false) + alert.success(result.message) + } else { + alert.error(result.error) + } + } catch { + alert.error('Chyba připojení') + } finally { + setTripSubmitting(false) + } + } + + const tripDistance = () => { + const s = parseInt(tripForm.start_km) || 0 + const e = parseInt(tripForm.end_km) || 0 + return e > s ? e - s : 0 + } + + const hasOngoingShift = dashData?.my_shift?.has_ongoing + const punchLabel = hasOngoingShift ? 'Zaznamenat odchod' : 'Zaznamenat příchod' + const quickActions = [] + + if (hasPermission('attendance.record')) { + quickActions.push({ + label: punching ? 'Odesílám...' : punchLabel, + color: hasOngoingShift ? 'danger' : 'success', + icon: hasOngoingShift + ? + : , + onClick: onPunch, + disabled: punching, + }) + } + if (hasPermission('offers.create')) { + quickActions.push({ label: 'Nová nabídka', path: '/offers/new', color: 'info', icon: }) + } + if (hasPermission('trips.record')) { + quickActions.push({ + label: 'Přidat jízdu', + color: 'warning', + icon: , + onClick: openTripModal, + }) + } + if (hasPermission('invoices.create')) { + quickActions.push({ label: 'Vystavit fakturu', path: '/invoices/new', color: 'danger', icon: }) + } + + return ( + <> + + {quickActions.map((action) => action.onClick ? ( + + ) : ( + + {action.icon} + {action.label} + + ))} + + + + {showTripModal && ( + +
setShowTripModal(false)} /> + +
+

Přidat jízdu

+
+
+
+
+
+ + + {tripErrors.vehicle_id && {tripErrors.vehicle_id}} +
+
+ + { + setTripForm(prev => ({ ...prev, trip_date: val })) + setTripErrors(prev => ({ ...prev, trip_date: undefined })) + }} + /> + {tripErrors.trip_date && {tripErrors.trip_date}} +
+
+ +
+
+ + { + setTripForm(prev => ({ ...prev, start_km: e.target.value })) + setTripErrors(prev => ({ ...prev, start_km: undefined })) + }} + className="admin-form-input" + min="0" + /> + {tripErrors.start_km && {tripErrors.start_km}} +
+
+ + { + setTripForm(prev => ({ ...prev, end_km: e.target.value })) + setTripErrors(prev => ({ ...prev, end_km: undefined })) + }} + className="admin-form-input" + min="0" + /> + {tripErrors.end_km && {tripErrors.end_km}} +
+
+ + +
+
+ +
+
+ + { + setTripForm(prev => ({ ...prev, route_from: e.target.value })) + setTripErrors(prev => ({ ...prev, route_from: undefined })) + }} + className="admin-form-input" + placeholder="Např. Praha" + /> + {tripErrors.route_from && {tripErrors.route_from}} +
+
+ + { + setTripForm(prev => ({ ...prev, route_to: e.target.value })) + setTripErrors(prev => ({ ...prev, route_to: undefined })) + }} + className="admin-form-input" + placeholder="Např. Brno" + /> + {tripErrors.route_to && {tripErrors.route_to}} +
+
+ +
+ + +
+ +
+ +