import type { ColumnDef } from "@edr/ui-common"; import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, TextInput, Title } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; import { canFleetAction, canFleetHardDelete, hasPermission, FREIGHT_PERMS, } from "@/lib/permissions"; import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { Inbox, Plus, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link, Navigate, useLocation } from "react-router-dom"; import FleetCardGrid from "@/components/fleet/FleetCardGrid"; import FleetFormDialog from "@/components/fleet/FleetFormDialog"; import FleetHistoryModal from "@/components/fleet/FleetHistoryModal"; import FleetRecordActions from "@/components/fleet/FleetRecordActions"; import FleetToolbar from "@/components/fleet/FleetToolbar"; import { matchesDayRange } from "@/hooks/useListControls"; import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal"; import WagonStatusActions from "@/components/wagons/WagonStatusActions"; import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal"; import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat"; import { useFleetViewMode } from "@/components/fleet/useFleetViewMode"; import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles"; import { useToast } from "@/hooks/use-toast"; import { FLEET_SELECT_NONE, getFleetResource, getFleetSlugFromPath, type FleetFormFieldDef, type FleetResourceSlug, } from "@/pages/fleet/config/resources"; import { isFleetPurgeable, isFleetServerPaginated, type FleetListFilters, type FleetRecord, } from "@/services/fleet/fleet.service"; import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common"; import { useDebouncedValue } from "@mantine/hooks"; const DEFAULT_SLUG: FleetResourceSlug = "locomotives"; const FleetResourcePage = () => { const location = useLocation(); const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG; const config = getFleetResource(slug); const { toast } = useToast(); const { user } = useAuth(); const canCreate = canFleetAction(user, slug, "create"); const canUpdate = canFleetAction(user, slug, "update"); const canDelete = canFleetAction(user, slug, "delete"); // Irreversible purge: only locomotives/wagons expose it, and it needs its own // grant — the coarse fleet:manage key deliberately does not unlock it. const canPurge = isFleetPurgeable(slug) && (slug === "locomotives" || slug === "wagons") && canFleetHardDelete(user, slug); // Wagon transfer workspace: shown only to holders of a transfer capability // (raise a request, fulfill one, or see the cross-yard history). const canTransfer = hasPermission(user, FREIGHT_PERMS.wagons.transferRequest) || hasPermission(user, FREIGHT_PERMS.wagons.transferFulfill) || hasPermission(user, FREIGHT_PERMS.wagons.transferHistoryAll); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 300); // Wagons and locomotives page in the database; the rest still list in full // and page in the browser (see `pagedHandlers` in fleet.service). const serverPaged = isFleetServerPaginated(slug); const [statusFilter, setStatusFilter] = useState("ALL"); // Registration date range. Server-side list filters (status/yard/train) are // applied by the API; this narrows what comes back, alongside search. const [dateFrom, setDateFrom] = useState(null); const [dateTo, setDateTo] = useState(null); const [listFilterValues, setListFilterValues] = useState>({}); const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [purgeTarget, setPurgeTarget] = useState(null); // Typing the record's own code is the confirmation — a purge cannot be undone, // so a single misplaced click must not be enough to trigger it. const [purgeConfirmText, setPurgeConfirmText] = useState(""); const [assigningDriver, setAssigningDriver] = useState(null); const [historyTarget, setHistoryTarget] = useState(null); const [selectedDriver, setSelectedDriver] = useState(""); const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false); const { viewMode, setViewMode } = useFleetViewMode(slug); const serverListFilters = useMemo((): FleetListFilters | undefined => { const serverFilteredSlugs: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"]; if (!serverFilteredSlugs.includes(slug)) return undefined; const filters: FleetListFilters = {}; const status = listFilterValues.status; const currentYardId = listFilterValues.currentYardId; const availability = listFilterValues.availability; const trainNumber = listFilterValues.trainNumber; const trainId = listFilterValues.trainId; if (status && status !== "ALL") { (filters as { status?: string }).status = status; } if (currentYardId && currentYardId !== "ALL") { filters.currentYardId = currentYardId; } if (availability && availability !== "ALL") { (filters as { availability?: string }).availability = availability; } if (trainNumber && trainNumber !== "ALL") { (filters as { trainNumber?: string }).trainNumber = trainNumber; } if (trainId && trainId !== "ALL") { filters.trainId = trainId; } // Wagons only: narrow the fleet to one wagon type (the API filters on it). const wagonTypeId = listFilterValues.wagonTypeId; if (wagonTypeId && wagonTypeId !== "ALL") { (filters as { wagonTypeId?: string }).wagonTypeId = wagonTypeId; } // The plain locomotives list has no server-side search — its page window // does, so the term is only sent on the paginated path. if ((serverPaged || slug !== "locomotives") && debouncedSearch.trim()) { filters.search = debouncedSearch.trim(); } return filters; }, [slug, listFilterValues, debouncedSearch, serverPaged]); // On the server-paged path the page window, the search and the registration // date range are all resolved by the API — nothing is filtered client-side. const pagedFilters = useMemo( (): FleetListFilters => ({ ...serverListFilters, page: pagination.pageIndex + 1, pageSize: pagination.pageSize, ...(dateFrom ? { createdFrom: dateFrom } : {}), ...(dateTo ? { createdTo: dateTo } : {}), }), [serverListFilters, pagination.pageIndex, pagination.pageSize, dateFrom, dateTo], ); const listQuery = useQuery({ ...api.fleet.list.queryOptions({ input: { slug, filters: serverListFilters } }), enabled: !serverPaged, }); const pagedQuery = useQuery({ ...api.fleet.listPaged.queryOptions({ input: { slug, filters: pagedFilters } }), enabled: serverPaged, placeholderData: keepPreviousData, }); const activeQuery = serverPaged ? pagedQuery : listQuery; const { isLoading, isError, error } = activeQuery; const allRows = useMemo( () => (serverPaged ? (pagedQuery.data?.items ?? []) : (listQuery.data ?? [])), [serverPaged, pagedQuery.data, listQuery.data], ); const create = useMutation(api.fleet.create.mutationOptions()); const update = useMutation(api.fleet.update.mutationOptions()); const remove = useMutation(api.fleet.remove.mutationOptions()); const purge = useMutation(api.fleet.purge.mutationOptions()); const { data: wagonTypes = [], isLoading: wagonTypesLoading } = useQuery( api.wagonTypes.list.queryOptions(), ); const { data: truckTypes = [], isLoading: truckTypesLoading } = useQuery( api.truckTypes.list.queryOptions(), ); const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery( api.containerTypes.list.queryOptions({ staleTime: Infinity }), ); const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery( api.cargoTypes.list.queryOptions({ staleTime: Infinity }), ); // Whole-fleet list for the "Wagon" form select — page-walked, so only fetch it // where a form actually offers that select (containers), not on every slug. const needsWagonOptions = Boolean( config?.formFields.some((field) => field.dynamicOptions === "wagons"), ); const { data: wagons = [], isLoading: wagonsLoading } = useQuery({ ...api.wagons.list.queryOptions({ input: {} }), enabled: needsWagonOptions, }); const { data: containers = [], isLoading: containersLoading } = useQuery( api.containers.list.queryOptions(), ); const { data: yards = [], isLoading: yardsLoading } = useQuery( api.routes.yards.queryOptions(), ); const { data: drivers = [] } = useQuery( api.fleet.list.queryOptions({ input: { slug: "drivers" } }), ); // Wagons-only: "Train" list filter needs every train's code to pick from. const { data: trains = [], isLoading: trainsLoading } = useQuery({ ...api.trains.list.queryOptions(), enabled: slug === "wagons", }); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); setSearch(""); setStatusFilter("ALL"); setListFilterValues({}); }, [slug, setPagination]); useEffect(() => { setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); }, [search, listFilterValues, dateFrom, dateTo, setPagination]); const hasStatusColumn = Boolean(config?.columns.some((col) => col.accessorKey === "status")); const usesServerListFilters = Boolean(config?.listFilters?.length); const statusFilterOptions = useMemo(() => { if (!hasStatusColumn || usesServerListFilters) return []; if (slug === "vehicles" || slug === "drivers") { return [ { value: "ALL", label: "All statuses" }, { value: "ACTIVE", label: "Active" }, { value: "INACTIVE", label: "Inactive" }, ]; } const statuses = new Set( allRows .map((row) => String((row as unknown as Record).status ?? "")) .filter(Boolean), ); return [ { value: "ALL", label: "All statuses" }, ...[...statuses].sort().map((status) => ({ value: status, label: status })), ]; }, [allRows, hasStatusColumn, usesServerListFilters, slug]); const dynamicOptions = useMemo(() => { const wagonTypeOpts = (wagonTypes as Array<{ id: string; code: string; name?: string }>).map( (t) => ({ value: t.id, label: `${t.code}${t.name ? ` - ${t.name}` : ""}` }), ); const containerTypeOpts = ( containerTypes as Array<{ id: string; label?: string; code?: string }> ).map((t) => ({ value: t.id, label: t.label ?? t.code ?? t.id })); const cargoTypeOpts = ( cargoTypes as Array<{ id: string; cargoTypeName?: string; code?: string }> ).map((t) => ({ value: t.id, label: t.cargoTypeName ?? t.code ?? t.id })); const wagonOpts = (wagons as Array<{ id: string; wagonNumber: string }>).map((w) => ({ value: w.id, label: w.wagonNumber, })); const containerOpts = (containers as Array<{ id: string; containerNumber: string }>).map( (c) => ({ value: c.id, label: c.containerNumber }), ); const yardOpts = (yards as Array<{ id: string; label?: string; code?: string }>).map( (y) => ({ value: y.id, label: y.label ?? y.code ?? y.id }), ); const trainOpts = (trains as Array<{ id: string; code: string; trainName?: string | null }>).map( (t) => ({ value: t.id, label: t.trainName ? `${t.code} - ${t.trainName}` : t.code }), ); // Carries capacity + trailer configuration so picking a truck type can // pre-fill the vehicle's capacity and drop the trailer plate on a rigid type. const truckTypeOpts = ( truckTypes as Array<{ id: string; code: string; name?: string; capacityTons?: number | null; hasTrailer?: boolean; }> ).map((t) => ({ value: t.id, label: t.name ? `${t.name} (${t.code})` : t.code, meta: { capacityTons: t.capacityTons, hasTrailer: t.hasTrailer }, })); registerFleetOptionLabels("currentYardId", yardOpts); return { wagonTypes: wagonTypeOpts, containerTypes: containerTypeOpts, cargoTypes: [{ label: "None", value: FLEET_SELECT_NONE }, ...cargoTypeOpts], truckTypes: truckTypeOpts, wagons: [{ label: "Unassigned", value: FLEET_SELECT_NONE }, ...wagonOpts], containers: containerOpts, yards: yardOpts, trains: trainOpts, }; }, [wagonTypes, containerTypes, cargoTypes, truckTypes, wagons, containers, yards, trains]); const listFilterSelects = useMemo(() => { if (!config?.listFilters?.length) return null; return config.listFilters.map((filter) => { const dynamicOpts = filter.dynamicOptions ? (dynamicOptions[filter.dynamicOptions] ?? []) : []; const staticOpts = filter.options?.map((opt) => ({ value: opt.value, label: opt.label })) ?? []; const opts = filter.dynamicOptions ? dynamicOpts : staticOpts; return { ...filter, value: listFilterValues[filter.key] ?? "ALL", data: [ { value: "ALL", label: filter.allLabel ?? `All ${filter.label.toLowerCase()}` }, ...opts, ], }; }); }, [config?.listFilters, listFilterValues, dynamicOptions]); useEffect(() => { registerFleetOptionLabels("wagonTypeId", dynamicOptions.wagonTypes); registerFleetOptionLabels("containerTypeId", dynamicOptions.containerTypes); registerFleetOptionLabels( "cargoTypeId", dynamicOptions.cargoTypes.filter((o) => o.value !== FLEET_SELECT_NONE), ); registerFleetOptionLabels("wagonId", dynamicOptions.wagons); registerFleetOptionLabels("containerId", dynamicOptions.containers); registerFleetOptionLabels("currentYardId", dynamicOptions.yards); registerFleetOptionLabels("locationId", dynamicOptions.yards); registerFleetOptionLabels("truckTypeId", dynamicOptions.truckTypes); }, [dynamicOptions]); const formFields = useMemo((): FleetFormFieldDef[] => { if (!config) return []; return config.formFields.map((field) => { if (!field.dynamicOptions) return field; const options = dynamicOptions[field.dynamicOptions] ?? []; return { ...field, type: "select" as const, options }; }); }, [config, dynamicOptions]); const selectOptionsLoading = wagonTypesLoading || containerTypesLoading || cargoTypesLoading || truckTypesLoading || wagonsLoading || containersLoading || yardsLoading || trainsLoading; const filteredRows = useMemo(() => { if (!config) return allRows; // The API already applied every filter and cut the page — re-filtering here // would drop rows the server deliberately returned. if (serverPaged) return allRows; const term = search.trim().toLowerCase(); return allRows.filter((row) => { const record = row as unknown as Record; // The date range applies even when the API already filtered the list — // it is not one of the server-side filters. if (!matchesDayRange(record.createdAt, dateFrom, dateTo)) return false; if (usesServerListFilters) return true; if (statusFilter !== "ALL" && String(record.status ?? "") !== statusFilter) { return false; } if (!term) return true; return config.searchKeys.some((key) => String(record[key] ?? "") .toLowerCase() .includes(term), ); }); }, [allRows, search, statusFilter, config, usesServerListFilters, dateFrom, dateTo, serverPaged]); const totalCount = serverPaged ? (pagedQuery.data?.meta.total ?? 0) : filteredRows.length; const pageCount = serverPaged ? Math.max(1, pagedQuery.data?.meta.totalPages ?? 1) : Math.max(1, Math.ceil(filteredRows.length / pagination.pageSize)); const pagedRows = useMemo(() => { if (serverPaged) return filteredRows; const start = pagination.pageIndex * pagination.pageSize; return filteredRows.slice(start, start + pagination.pageSize); }, [filteredRows, pagination.pageIndex, pagination.pageSize, serverPaged]); const columns = useMemo((): ColumnDef[] => { if (!config) return []; const headerClassName = ruleEngineTable.headerCell; const cellClassName = ruleEngineTable.bodyCell; const base: ColumnDef[] = config.columns.map((col) => ({ id: col.id, accessorKey: col.accessorKey, header: col.header, size: col.size || 150, minSize: col.size ? Math.max(col.size - 20, 80) : 80, meta: { headerClassName, cellClassName }, cell: ({ row }) => { const value = (row.original as unknown as Record)[col.accessorKey]; return formatFleetCell(value, col.format, col.accessorKey); }, })); base.push({ id: "actions", header: "Actions", // Wagons carry the inline maintenance toggle, which needs more room. size: config.slug === "wagons" ? 240 : 160, meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` }, cell: ({ row }) => (
e.stopPropagation()} data-stop-row-click> {config.slug === "wagons" ? ( ) : null} { setEditing(record); setFormOpen(true); } : undefined } onRemove={canDelete ? setRemoveTarget : undefined} onPurge={canPurge ? setPurgeTarget : undefined} onAssignDriver={canUpdate ? setAssigningDriver : undefined} onHistory={setHistoryTarget} />
), }); return base; }, [config, dynamicOptions.yards, canUpdate, canDelete]); const tableStatus = isLoading ? "loading" : isError ? "error" : "success"; if (!config) { return ; } const handleFormSubmit = async (values: Record) => { try { if (editing && "id" in editing) { // PATCH only the fields the user actually changed. Re-sending the whole // form used to re-submit status/currentYardId on every save — which, // for a locomotive/wagon coupled to a built train, silently diverged // the consist (editing a name could move the loco to another yard). const editingRecord = editing as unknown as Record; const changed = Object.fromEntries( Object.entries(values).filter( ([key, value]) => value !== editingRecord[key], ), ); await update.mutateAsync({ slug, id: String(editing.id), data: changed }); toast({ title: `${config.entityLabel} updated` }); } else { await create.mutateAsync({ slug, data: values }); toast({ title: `${config.entityLabel} created` }); } setFormOpen(false); setEditing(null); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Save failed"; toast({ title: "Save failed", description: String(message), variant: "destructive" }); } }; const handleRemove = async () => { if (!removeTarget || !("id" in removeTarget)) return; try { await remove.mutateAsync({ slug, id: String(removeTarget.id) }); toast({ title: config.removeSuccessMessage ?? `${config.entityLabel} removed`, }); setRemoveTarget(null); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Remove failed"; toast({ title: "Remove failed", description: String(message), variant: "destructive" }); } }; /** The code the operator must retype to confirm a purge. */ const purgeFields = purgeTarget ? (purgeTarget as unknown as Record) : null; const purgeLabel = purgeFields ? String(purgeFields.wagonNumber ?? purgeFields.code ?? "") : ""; const closePurge = () => { setPurgeTarget(null); setPurgeConfirmText(""); }; const handlePurge = async () => { if (!purgeTarget || !("id" in purgeTarget)) return; try { await purge.mutateAsync({ slug, id: String(purgeTarget.id) }); toast({ title: `${config.entityLabel} permanently deleted` }); closePurge(); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Permanent delete failed"; toast({ title: "Permanent delete failed", description: String(message), variant: "destructive", }); } }; const handleAssignDriver = async () => { if (!assigningDriver || !("id" in assigningDriver) || !selectedDriver) return; try { const selectedDriverRecord = (drivers as unknown as Array>).find( (d) => String(d.id) === selectedDriver ); if (!selectedDriverRecord) return; const driverName = `${selectedDriverRecord.firstName} ${selectedDriverRecord.lastName}`; await update.mutateAsync({ slug, id: String(assigningDriver.id), data: { assignedDriverId: selectedDriver, assignedDriverName: driverName, }, }); toast({ title: "Driver assigned successfully" }); setAssigningDriver(null); setSelectedDriver(""); } catch (err: unknown) { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Assignment failed"; toast({ title: "Assignment failed", description: String(message), variant: "destructive" }); } }; const itemLabel = config.label.toLowerCase(); return (
{config.label} {config.subtitle}
{slug === "wagons" ? ( <> {canUpdate ? ( ) : null} {canTransfer ? ( // The desk is its own page now (list + fulfil + history with // pagination); this is just the way in from the fleet list. ) : null} ) : null} {canCreate ? ( ) : null}
{ setDateFrom(from); setDateTo(to); }} presets={getDateRangePresets()} clearable size="sm" radius="lg" w={240} /> {listFilterSelects ? ( {listFilterSelects.map((filter) => ( setSelectedDriver(value || "")} data={(drivers as unknown as Array>).map((driver) => ({ value: String(driver.id || ""), label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`, }))} /> {slug === "wagons" ? ( setWagonWorkspaceOpen(false)} /> ) : null} {slug === "wagons" ? ( setHistoryTarget(null)} record={historyTarget} /> ) : ( setHistoryTarget(null)} entity={slug === "vehicles" ? "vehicle" : "driver"} record={historyTarget} /> )}
); }; export default FleetResourcePage;