mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Replaces every remaining split from/to Mantine DateInput/DatePickerInput pair with a single DatePickerInput type="range", sharing one preset list (Today, Last 7/30 days, this/last month, YTD) via getDateRangePresets(). Covers ListControls (18 consumers), FleetResourcePage, ContractRequestsPage, BookingRequestsPage (created + scheduled ranges), WagonCancellationsPage, BatchBoardPage, ClearanceDocumentsPage, ShipmentRequestsPage. Native Mantine range picker, not the shadcn DateRangePicker, to match each page's existing design system instead of clashing with it. Reports date-range filter (ReportFilters.tsx) intentionally left untouched.
900 lines
34 KiB
TypeScript
900 lines
34 KiB
TypeScript
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<string | null>(null);
|
|
const [dateTo, setDateTo] = useState<string | null>(null);
|
|
const [listFilterValues, setListFilterValues] = useState<Record<string, string>>({});
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [editing, setEditing] = useState<FleetRecord | null>(null);
|
|
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
|
|
const [purgeTarget, setPurgeTarget] = useState<FleetRecord | null>(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<FleetRecord | null>(null);
|
|
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
|
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
|
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<string, unknown>).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<string, unknown>;
|
|
// 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<FleetRecord>[] => {
|
|
if (!config) return [];
|
|
const headerClassName = ruleEngineTable.headerCell;
|
|
const cellClassName = ruleEngineTable.bodyCell;
|
|
|
|
const base: ColumnDef<FleetRecord>[] = 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<string, unknown>)[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 }) => (
|
|
<div onClick={(e) => e.stopPropagation()} data-stop-row-click>
|
|
<Group gap={4} wrap="nowrap">
|
|
{config.slug === "wagons" ? (
|
|
<WagonStatusActions record={row.original} canUpdate={canUpdate} />
|
|
) : null}
|
|
<FleetRecordActions
|
|
record={row.original}
|
|
config={config}
|
|
layout="compact"
|
|
onEdit={
|
|
canUpdate
|
|
? (record) => {
|
|
setEditing(record);
|
|
setFormOpen(true);
|
|
}
|
|
: undefined
|
|
}
|
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
|
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
|
onHistory={setHistoryTarget}
|
|
/>
|
|
</Group>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
return base;
|
|
}, [config, dynamicOptions.yards, canUpdate, canDelete]);
|
|
|
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
|
|
|
if (!config) {
|
|
return <Navigate to="/dashboard/locomotives" replace />;
|
|
}
|
|
|
|
const handleFormSubmit = async (values: Record<string, unknown>) => {
|
|
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<string, unknown>;
|
|
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<string, unknown>)
|
|
: 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<Record<string, unknown>>).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 (
|
|
<Container size="xxl" py="lg" px="lg">
|
|
<Breadcrumbs items={[{ label: config.label }]} />
|
|
|
|
<Stack gap="lg" mt="sm">
|
|
<Group justify="space-between" align="flex-end">
|
|
<div>
|
|
<Title order={2}>{config.label}</Title>
|
|
<Text c="dimmed" size="sm">
|
|
{config.subtitle}
|
|
</Text>
|
|
</div>
|
|
<Group gap="sm">
|
|
{slug === "wagons" ? (
|
|
<>
|
|
{canUpdate ? (
|
|
<Button
|
|
variant="light"
|
|
color="edr-green"
|
|
leftSection={<Warehouse size={16} />}
|
|
styles={{ label: { fontWeight: 500 } }}
|
|
onClick={() => setWagonWorkspaceOpen(true)}
|
|
>
|
|
Yard Workspace
|
|
</Button>
|
|
) : null}
|
|
{canTransfer ? (
|
|
// The desk is its own page now (list + fulfil + history with
|
|
// pagination); this is just the way in from the fleet list.
|
|
<Button
|
|
component={Link}
|
|
to="/dashboard/wagon-transfers"
|
|
variant="light"
|
|
color="grape"
|
|
leftSection={<Inbox size={16} />}
|
|
styles={{ label: { fontWeight: 500 } }}
|
|
>
|
|
Transfer Requests
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{canCreate ? (
|
|
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
|
setEditing(null);
|
|
setFormOpen(true);
|
|
}}>
|
|
{config.addLabel}
|
|
</Button>
|
|
) : null}
|
|
</Group>
|
|
</Group>
|
|
|
|
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
|
<Stack gap={0}>
|
|
<Box px="md" pt="md" pb="md" w="100%" style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}>
|
|
<FleetToolbar
|
|
search={search}
|
|
onSearchChange={setSearch}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
showSearch={config.supportsSearch}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
filters={
|
|
<Group gap="sm" wrap="wrap" align="center">
|
|
<DatePickerInput
|
|
type="range"
|
|
aria-label="Created date range"
|
|
placeholder="Created date range"
|
|
value={[dateFrom, dateTo]}
|
|
onChange={([from, to]) => {
|
|
setDateFrom(from);
|
|
setDateTo(to);
|
|
}}
|
|
presets={getDateRangePresets()}
|
|
clearable
|
|
size="sm"
|
|
radius="lg"
|
|
w={240}
|
|
/>
|
|
{listFilterSelects ? (
|
|
<Group gap="sm" wrap="wrap" align="center">
|
|
{listFilterSelects.map((filter) => (
|
|
<Select
|
|
key={filter.key}
|
|
aria-label={filter.label}
|
|
placeholder={filter.data[0]?.label ?? filter.label}
|
|
data={filter.data}
|
|
value={filter.value}
|
|
onChange={(value) => {
|
|
setListFilterValues((prev) => ({
|
|
...prev,
|
|
[filter.key]: value ?? "ALL",
|
|
}));
|
|
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
|
}}
|
|
size="sm"
|
|
radius="lg"
|
|
w={200}
|
|
searchable={filter.data.length > 8}
|
|
comboboxProps={{ withinPortal: true }}
|
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
|
/>
|
|
))}
|
|
</Group>
|
|
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
|
<Group gap={4} wrap="wrap">
|
|
<Text size="xs" fw={500} c="dimmed">Status:</Text>
|
|
<Group gap={4} wrap="wrap">
|
|
{[{ value: "ALL", label: "All" }, ...statusFilterOptions].map((option) => (
|
|
<Button
|
|
key={option.value}
|
|
size="xs"
|
|
radius="md"
|
|
variant={statusFilter === option.value ? "filled" : "outline"}
|
|
styles={{ label: { fontWeight: 500 } }}
|
|
onClick={() => setStatusFilter(option.value)}
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
))}
|
|
</Group>
|
|
</Group>
|
|
) : null}
|
|
</Group>
|
|
}
|
|
/>
|
|
</Box>
|
|
|
|
{viewMode === "table" ? (
|
|
<Box style={{
|
|
overflowX: "auto",
|
|
width: "100%",
|
|
WebkitOverflowScrolling: "touch",
|
|
}}>
|
|
<DataTable
|
|
columns={columns}
|
|
data={pagedRows}
|
|
status={tableStatus}
|
|
error={
|
|
isError
|
|
? {
|
|
message: "Failed to load data",
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
}
|
|
: undefined
|
|
}
|
|
emptyMessage={`No ${itemLabel} found`}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount,
|
|
totalCount,
|
|
}}
|
|
tableOptions={{
|
|
manualPagination: true,
|
|
pageCount,
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
footer={({ table, pagination: footerPagination }) => (
|
|
<DataTableFooter
|
|
table={table}
|
|
pagination={footerPagination}
|
|
options={{ labels: { items: itemLabel } }}
|
|
/>
|
|
)}
|
|
/>
|
|
</Box>
|
|
) : (
|
|
<FleetCardGrid
|
|
config={config}
|
|
rows={pagedRows}
|
|
status={tableStatus}
|
|
emptyMessage={`No ${itemLabel} found`}
|
|
pagination={pagination}
|
|
pageCount={pageCount}
|
|
totalCount={totalCount}
|
|
onPaginationChange={setPagination}
|
|
onEdit={
|
|
canUpdate
|
|
? (record) => {
|
|
setEditing(record);
|
|
setFormOpen(true);
|
|
}
|
|
: undefined
|
|
}
|
|
onRemove={canDelete ? setRemoveTarget : undefined}
|
|
onPurge={canPurge ? setPurgeTarget : undefined}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
|
|
<FleetFormDialog
|
|
open={formOpen}
|
|
onOpenChange={(open) => {
|
|
setFormOpen(open);
|
|
if (!open) setEditing(null);
|
|
}}
|
|
title={editing ? `Edit ${config.entityLabel}` : config.addLabel}
|
|
fields={formFields}
|
|
initialRecord={editing}
|
|
emptyValues={config.emptyValues}
|
|
isSubmitting={create.isPending || update.isPending}
|
|
selectOptionsLoading={selectOptionsLoading}
|
|
onSubmit={handleFormSubmit}
|
|
verifyWithFayda={Boolean(config.faydaVerification)}
|
|
/>
|
|
|
|
<Modal
|
|
opened={Boolean(removeTarget)}
|
|
onClose={() => setRemoveTarget(null)}
|
|
title={<Text fw={600}>{config.removeActionLabel ?? "Delete"}</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
{config.removeConfirmMessage ??
|
|
`Are you sure you want to ${config.removeAction} this ${config.entityLabel.toLowerCase()}?`}
|
|
</Text>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => setRemoveTarget(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button color="red" loading={remove.isPending} onClick={handleRemove}>
|
|
{config.removeActionLabel ?? "Delete"}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={Boolean(purgeTarget)}
|
|
onClose={closePurge}
|
|
title={<Text fw={600}>Delete permanently</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
This permanently removes{" "}
|
|
<Text span fw={700}>
|
|
{purgeLabel || `this ${config.entityLabel.toLowerCase()}`}
|
|
</Text>{" "}
|
|
from the database. It cannot be undone.
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
Only unused records can be purged — if it has any history or is still
|
|
referenced, the request is refused and you should use{" "}
|
|
{(config.removeActionLabel ?? "Delete").toLowerCase()} instead.
|
|
</Text>
|
|
<TextInput
|
|
label={`Type ${purgeLabel} to confirm`}
|
|
placeholder={purgeLabel}
|
|
value={purgeConfirmText}
|
|
onChange={(e) => setPurgeConfirmText(e.currentTarget.value)}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={closePurge}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
loading={purge.isPending}
|
|
disabled={purgeConfirmText.trim() !== purgeLabel || !purgeLabel}
|
|
onClick={handlePurge}
|
|
>
|
|
Delete permanently
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
<Modal
|
|
opened={Boolean(assigningDriver)}
|
|
onClose={() => {
|
|
setAssigningDriver(null);
|
|
setSelectedDriver("");
|
|
}}
|
|
title={<Text fw={600}>Assign Driver</Text>}
|
|
radius="lg"
|
|
centered
|
|
>
|
|
<Stack gap="md">
|
|
<Text size="sm">
|
|
{assigningDriver && "id" in assigningDriver ?
|
|
`Assign a driver to vehicle: ${(assigningDriver as unknown as Record<string, unknown>).plateNumber}`
|
|
: "Select a driver to assign"}
|
|
</Text>
|
|
<Select
|
|
label="Driver"
|
|
placeholder="Select a driver"
|
|
searchable
|
|
clearable
|
|
value={selectedDriver}
|
|
onChange={(value) => setSelectedDriver(value || "")}
|
|
data={(drivers as unknown as Array<Record<string, unknown>>).map((driver) => ({
|
|
value: String(driver.id || ""),
|
|
label: `${driver.firstName} ${driver.lastName} (${driver.licenseNumber})`,
|
|
}))}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button variant="default" onClick={() => {
|
|
setAssigningDriver(null);
|
|
setSelectedDriver("");
|
|
}}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleAssignDriver}
|
|
disabled={!selectedDriver}
|
|
loading={update.isPending}
|
|
>
|
|
Assign
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Modal>
|
|
|
|
{slug === "wagons" ? (
|
|
<WagonYardWorkspaceModal
|
|
opened={wagonWorkspaceOpen}
|
|
onClose={() => setWagonWorkspaceOpen(false)}
|
|
/>
|
|
) : null}
|
|
|
|
{slug === "wagons" ? (
|
|
<WagonMovementHistoryModal
|
|
opened={Boolean(historyTarget)}
|
|
onClose={() => setHistoryTarget(null)}
|
|
record={historyTarget}
|
|
/>
|
|
) : (
|
|
<FleetHistoryModal
|
|
opened={Boolean(historyTarget)}
|
|
onClose={() => setHistoryTarget(null)}
|
|
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
|
record={historyTarget}
|
|
/>
|
|
)}
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default FleetResourcePage;
|