import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link as RouterLink } from "react-router-dom"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; import IconButton from "@mui/material/IconButton"; import { useAlert } from "../context/AlertContext"; import { useAuth } from "../context/AuthContext"; import Forbidden from "../components/Forbidden"; import { tripListOptions, tripStatsOptions, tripVehiclesOptions, tripUsersOptions, type BackendTrip, type TripStats, } from "../lib/queries/trips"; import { companySettingsOptions } from "../lib/queries/settings"; import { jsonQuery } from "../lib/apiAdapter"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { formatDate } from "../utils/attendanceHelpers"; import { formatKm } from "../utils/formatters"; import { useApiMutation } from "../lib/queries/mutations"; import { Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, Select, DateField, MonthField, Pagination, StatusChip, FilterBar, LoadingState, EmptyState, StatCard, PageEnter, type DataColumn, } from "../ui"; const API_BASE = "/api/admin"; interface Trip { id: number; vehicle_id: number | string; trip_date: string; start_km: number; end_km: number; distance: number; route_from: string; route_to: string; is_business: number | boolean; notes?: string; spz: string; driver_name: string; } interface EditForm { vehicle_id: string; trip_date: string; start_km: string | number; end_km: string | number; route_from: string; route_to: string; is_business: number; notes: string; } function mapTrip(bt: BackendTrip): Trip { const distance = bt.distance ?? bt.end_km - bt.start_km; return { id: bt.id, vehicle_id: bt.vehicle_id, trip_date: bt.trip_date, start_km: bt.start_km, end_km: bt.end_km, distance, route_from: bt.route_from, route_to: bt.route_to, is_business: bt.is_business ? 1 : 0, notes: bt.notes || undefined, spz: bt.vehicles?.spz ?? "", driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : "", }; } /** * Escape user-origin strings before interpolating them into the print HTML * (the JSX hidden-div approach escaped automatically; the string template * must do it explicitly). */ function escapeHtml(value: string): string { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } const PRINT_STYLES = ` * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 10px; line-height: 1.4; color: #000; background: #fff; padding: 10mm; } .print-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; padding-bottom: 10px; border-bottom: 2px solid #333; } .print-header-left { display: flex; align-items: center; gap: 12px; } .print-logo { height: 40px; width: auto; } .print-header-text { text-align: left; } .print-header-right { text-align: right; } .print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; } .print-header .company { font-size: 11px; color: #666; } .print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; } .print-header .filters { font-size: 10px; color: #666; } .print-header .generated { font-size: 9px; color: #888; margin-top: 5px; } .summary { display: flex; justify-content: space-around; margin-bottom: 15px; padding: 10px; background: #f5f5f5; border: 1px solid #ddd; } .summary-item { text-align: center; } .summary-value { font-size: 14px; font-weight: 700; } .summary-label { font-size: 9px; color: #666; } table { width: 100%; border-collapse: collapse; margin-bottom: 15px; } th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; } th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; } td { font-size: 9px; } tr:nth-child(even) { background: #f9f9f9; } .text-center { text-align: center; } .text-right { text-align: right; } tfoot td { background: #eee; font-weight: 600; } .badge { display: inline-block; padding: 1px 4px; border-radius: 2px; font-size: 8px; font-weight: 500; } .badge-success { background: #dcfce7; color: #16a34a; } .badge-warning { background: #fef3c7; color: #d97706; } @media print { body { padding: 5mm; } @page { size: A4 landscape; margin: 5mm; } thead { display: table-header-group; } } `; /** * Build the full print document from the fetched /trips/print data set — * no hidden-div/innerHTML round-trip, so there is nothing to race against * React's commit. */ function buildPrintHtml(opts: { trips: Trip[]; totals: TripStats; periodName: string; companyName: string; vehicleName: string | null; userName: string | null; logoUrl: string; }): string { const { trips, totals } = opts; const rows = trips .map( (trip) => ` ${formatDate(trip.trip_date)} ${escapeHtml(trip.driver_name)} ${escapeHtml(trip.spz)} ${escapeHtml(trip.route_from)} → ${escapeHtml(trip.route_to)} ${formatKm(trip.start_km)} - ${formatKm(trip.end_km)} ${formatKm(trip.distance)} km ${trip.is_business ? "Služební" : "Soukromá"} ${escapeHtml(trip.notes || "")} `, ) .join(""); return ` Kniha jízd - ${escapeHtml(opts.periodName)}
${totals.count}
Počet jízd
${formatKm(totals.total_km)} km
Celkem
${formatKm(totals.business_km)} km
Služební
${formatKm(totals.private_km)} km
Soukromé
${rows}
Datum Řidič Vozidlo Trasa Stav km Vzdálenost Typ Poznámka
Celkem: ${formatKm(totals.total_km)} km
`; } const PrintIcon = ( ); const EditIcon = ( ); const DeleteIcon = ( ); const TripsIcon = ( ); const TotalIcon = ( ); const BusinessIcon = ( ); export default function TripsAdmin() { const alert = useAlert(); const { hasPermission } = useAuth(); const [filterPeriod, setFilterPeriod] = useState(() => { const now = new Date(); return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; }); const [filterVehicleId, setFilterVehicleId] = useState(""); const [filterUserId, setFilterUserId] = useState(""); const [page, setPage] = useState(1); const [showEditModal, setShowEditModal] = useState(false); const [editingTrip, setEditingTrip] = useState(null); const [editForm, setEditForm] = useState({ vehicle_id: "", trip_date: "", start_km: "", end_km: "", route_from: "", route_to: "", is_business: 1, notes: "", }); const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null; }>({ show: false, trip: null }); const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions()); const vehicles = vehiclesData; const { data: tripUsersData = [] } = useQuery(tripUsersOptions()); const tripUsers = tripUsersData; const { data: companySettings } = useQuery(companySettingsOptions()); const companyName = companySettings?.company_name ?? ""; // ONE shared filter object for the list, stats AND print requests so the // three can never drift apart. An empty MonthField value yields // month/year = undefined (omitted from the request), mirroring the // `Number(...) || undefined` guard the queries always used. const filters = { month: Number(filterPeriod.slice(5, 7)) || undefined, year: Number(filterPeriod.slice(0, 4)) || undefined, vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined, userId: filterUserId ? Number(filterUserId) : undefined, }; const { items: tripsItems, pagination, isPending, } = usePaginatedQuery(tripListOptions({ ...filters, page })); const trips = tripsItems.map(mapTrip); // Stat cards: totals over the WHOLE filtered set (server-side aggregate), // not just the visible page. const { data: stats } = useQuery(tripStatsOptions(filters)); // useApiMutation JSON.stringifies the whole TIn as the request body, so // TIn must match the backend schema (UpdateTripSchema) shape directly — // NOT a wrapper that nests the body. id is captured in the URL closure. const editMutation = useApiMutation< { id: number; vehicle_id: string; trip_date: string; start_km: string | number; end_km: string | number; route_from: string; route_to: string; is_business: number; notes: string; }, { message?: string; error?: string } >({ url: ({ id }) => `${API_BASE}/trips/${id}`, method: () => "PUT", invalidate: ["trips", "vehicles"], }); const deleteMutation = useApiMutation< number, { message?: string; error?: string } >({ url: (id) => `${API_BASE}/trips/${id}`, method: () => "DELETE", invalidate: ["trips", "vehicles"], onSuccess: (data) => { setDeleteConfirm({ show: false, trip: null }); alert.success(data?.message || "Smazáno"); }, }); if (!hasPermission("trips.manage")) return ; const openEditModal = (trip: Trip) => { setEditingTrip(trip); setEditForm({ vehicle_id: String(trip.vehicle_id), trip_date: trip.trip_date, start_km: trip.start_km, end_km: trip.end_km, route_from: trip.route_from, route_to: trip.route_to, is_business: Number(trip.is_business), notes: trip.notes || "", }); setShowEditModal(true); }; const handleEditSubmit = async () => { if (!editingTrip) return; if ( parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km)) ) { alert.error("Konečný stav km musí být větší než počáteční"); return; } try { const result = await editMutation.mutateAsync({ id: editingTrip.id, vehicle_id: editForm.vehicle_id, trip_date: editForm.trip_date, start_km: editForm.start_km, end_km: editForm.end_km, route_from: editForm.route_from, route_to: editForm.route_to, is_business: editForm.is_business, notes: editForm.notes, }); setShowEditModal(false); alert.success(result?.message || "Upraveno"); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } }; const handleDelete = async () => { if (!deleteConfirm.trip) return; try { await deleteMutation.mutateAsync(deleteConfirm.trip.id); } catch (e) { alert.error(e instanceof Error ? e.message : "Chyba připojení"); } }; const getPeriodName = () => filterPeriod ? new Date( Number(filterPeriod.slice(0, 4)), Number(filterPeriod.slice(5, 7)) - 1, ).toLocaleString("cs-CZ", { month: "long", year: "numeric" }) : "Všechna období"; const getSelectedVehicleName = () => { if (!filterVehicleId) return null; const v = vehicles.find((v) => String(v.id) === filterVehicleId); return v ? `${v.spz} - ${v.name}` : null; }; const getSelectedUserName = () => { if (!filterUserId) return null; const u = tripUsers.find((u) => String(u.id) === filterUserId); return u?.name || null; }; const handlePrint = async () => { // Open the print window SYNCHRONOUSLY, inside the click's transient // activation — calling window.open after the network await can fall // outside the activation window and get popup-blocked (which previously // failed silently). const printWindow = window.open("", "_blank"); if (!printWindow) { alert.error("Tisk byl zablokován prohlížečem"); return; } // Fetch the FULL filtered set for the printout — the list query is // paginated, so printing from it would truncate to the visible page. let printTrips: Trip[]; let printTotals: TripStats; try { const params = new URLSearchParams(); if (filters.month) params.set("month", String(filters.month)); if (filters.year) params.set("year", String(filters.year)); if (filters.vehicleId) params.set("vehicle_id", String(filters.vehicleId)); if (filters.userId) params.set("user_id", String(filters.userId)); const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>( `${API_BASE}/trips/print?${params.toString()}`, ); printTrips = data.trips.map(mapTrip); printTotals = data.totals; } catch (e) { printWindow.close(); console.error("Trip print data fetch failed:", e); alert.error(e instanceof Error ? e.message : "Chyba připojení"); return; } // The user closed the popup while the data was loading — a deliberate // cancel, not an error. if (printWindow.closed) return; // The document is built directly from the fetched data (no hidden-div / // setTimeout round-trip), so it can be neither unmounted nor stale. printWindow.document.write( buildPrintHtml({ trips: printTrips, totals: printTotals, periodName: getPeriodName(), companyName, vehicleName: getSelectedVehicleName(), userName: getSelectedUserName(), logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`, }), ); printWindow.document.close(); printWindow.onload = () => { printWindow.print(); }; }; const calculateDistance = (): number => { const start = parseInt(String(editForm.start_km)) || 0; const end = parseInt(String(editForm.end_km)) || 0; return end > start ? end - start : 0; }; const totals = { count: stats?.count ?? 0, total: stats?.total_km ?? 0, business: stats?.business_km ?? 0, }; const columns: DataColumn[] = [ { key: "trip_date", header: "Datum", width: "11%", mono: true, render: (trip) => formatDate(trip.trip_date), }, { key: "driver", header: "Řidič", width: "16%", render: (trip) => trip.driver_name, }, { key: "vehicle", header: "Vozidlo", width: "11%", mono: true, render: (trip) => , }, { key: "route", header: "Trasa", width: "20%", render: (trip) => ( {trip.route_from} → {trip.route_to} ), }, { key: "km", header: "Stav km", width: "14%", mono: true, render: (trip) => ( {formatKm(trip.start_km)} - {formatKm(trip.end_km)} ), }, { key: "distance", header: "Vzdálenost", width: "12%", mono: true, bold: true, render: (trip) => `${formatKm(trip.distance)} km`, }, { key: "type", header: "Typ", width: "10%", render: (trip) => ( ), }, { key: "actions", header: "Akce", width: "10%", align: "right", render: (trip) => ( openEditModal(trip)} aria-label="Upravit" title="Upravit" > {EditIcon} setDeleteConfirm({ show: true, trip })} aria-label="Smazat" title="Smazat" > {DeleteIcon} ), }, ]; return ( Správa knihy jízd {(pagination?.total ?? 0) > 0 && ( )} {/* Filters */} { setFilterPeriod(val); setPage(1); }} /> { setFilterUserId(value); setPage(1); }} options={[ { value: "", label: "Všichni řidiči" }, ...tripUsers.map((u) => ({ value: String(u.id), label: u.name, })), ]} /> {/* Stats Cards */} {/* Trips Table */} {isPending ? ( ) : ( <> columns={columns} rows={trips} rowKey={(trip) => trip.id} empty={ } /> )} {/* Edit Modal */} setShowEditModal(false)} onSubmit={handleEditSubmit} title="Upravit jízdu" subtitle={editingTrip?.driver_name} loading={editMutation.isPending} maxWidth="lg" > {editingTrip && ( <> setEditForm({ ...editForm, is_business: parseInt(value) }) } options={[ { value: "1", label: "Služební" }, { value: "0", label: "Soukromá" }, ]} /> setEditForm({ ...editForm, notes: e.target.value }) } /> )} {/* Delete Confirmation */} setDeleteConfirm({ show: false, trip: null })} onConfirm={handleDelete} title="Smazat záznam" message={ deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : "" } confirmText="Smazat" confirmVariant="danger" loading={deleteMutation.isPending} /> ); }