mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
The real live fleet page — FleetCrudPages.tsx (previous commit) turned
out to be dead code; every fleet route (locomotives/trains/wagons/
containers/cargoes/vehicles/drivers) actually renders this one,
config-driven off resources.ts. Highest-leverage single file in the
remaining inventory: 7 routes at once.
- filterDefs are built per slug from `config.listFilters` (status/yard/
wagon type/train…, wired to real server params via each def's
default `{key: value}` toParams) plus a "Registered" date range. The
3 slugs with no server list filters (trains/containers/cargoes) fall
back to a plain client-only Status filter off a fixed enum, not
"whatever status exists in the currently-loaded rows" — the latter
would be circular (filterDefs feeds useFilters, which feeds the
query that produces those rows).
- serverListFilters/pagedFilters derive straight from `controls.params`
instead of hand-picking each field off local state — the def keys
already match the API's param names, so this is mostly free.
- filteredRows keeps the original's exact "usesServerListFilters ⇒
trust the server, don't re-check client-side" short-circuit — some
server list filters (a wagon's `trainNumber` matches either of two
different columns) aren't expressible as a plain client-side field
equality, and re-applying them would silently break.
- Pagination moved from a local `usePagination()` (component state) to
useFilters' URL-backed page/pageSize — the whole point of this pass.
DataTable takes `controls.tableProps(total)` directly; FleetCardGrid
(not a DataTable) is fed the same pieces by hand.
- The two manual "reset on slug/filter change" effects are dropped —
a same-app nav Link to a bare path already clears the query string,
and useFilters already deletes `page` on every filter/search change.
- FleetToolbar's view-mode SegmentedControl moves into FilterBar's
`children` slot; its search/filters props are no longer used here.
837 lines
32 KiB
TypeScript
837 lines
32 KiB
TypeScript
import type { ColumnDef } from "@edr/ui-common";
|
|
import { Box, Button, Card, Container, Group, Modal, SegmentedControl, Select, Stack, Text, TextInput, Title } from "@mantine/core";
|
|
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, LayoutGrid, Plus, Table2, 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 { 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, type FleetViewMode } 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 } from "@edr/ui-common";
|
|
import { dateRangeParams, FilterBar, useFilters, type FilterDef, type FilterOption } from "@/components/filters";
|
|
|
|
const DEFAULT_SLUG: FleetResourceSlug = "locomotives";
|
|
|
|
const SERVER_FILTERED_SLUGS: FleetResourceSlug[] = ["wagons", "locomotives", "vehicles", "drivers"];
|
|
|
|
// trains/containers/cargoes have no server-side `listFilters` config (see
|
|
// resources.ts) — they get a plain client-only Status filter instead, off a
|
|
// fixed enum rather than "whatever status happens to exist in the currently
|
|
// loaded rows" (which would create a circular dependency: filterDefs feeds
|
|
// useFilters, which feeds the query that produces those rows).
|
|
const TRAIN_STATUS_OPTIONS: FilterOption[] = [
|
|
{ value: "AVAILABLE", label: "Available" },
|
|
{ value: "SCHEDULED", label: "Scheduled" },
|
|
{ value: "IN_SERVICE", label: "In service" },
|
|
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
|
|
{ value: "OUT_OF_SERVICE", label: "Out of service" },
|
|
{ value: "DEACTIVATED", label: "Deactivated" },
|
|
];
|
|
const CONTAINER_STATUS_OPTIONS: FilterOption[] = [
|
|
{ value: "AVAILABLE", label: "Available" },
|
|
{ value: "LOADED", label: "Loaded" },
|
|
{ value: "IN_TRANSIT", label: "In transit" },
|
|
{ value: "MAINTENANCE", label: "Maintenance" },
|
|
{ value: "DAMAGED", label: "Damaged" },
|
|
];
|
|
const CARGO_STATUS_OPTIONS: FilterOption[] = [
|
|
{ value: "PENDING", label: "Pending" },
|
|
{ value: "LOADED", label: "Loaded" },
|
|
{ value: "IN_TRANSIT", label: "In transit" },
|
|
{ value: "DELIVERED", label: "Delivered" },
|
|
{ value: "UNLOADED", label: "Unloaded" },
|
|
];
|
|
const FALLBACK_STATUS_OPTIONS: Partial<Record<FleetResourceSlug, FilterOption[]>> = {
|
|
trains: TRAIN_STATUS_OPTIONS,
|
|
containers: CONTAINER_STATUS_OPTIONS,
|
|
cargoes: CARGO_STATUS_OPTIONS,
|
|
};
|
|
|
|
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);
|
|
|
|
// 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 [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 { 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",
|
|
});
|
|
|
|
const usesServerListFilters = Boolean(config?.listFilters?.length);
|
|
|
|
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]);
|
|
|
|
// One pill per configured server list filter (status/yard/wagon type/train…),
|
|
// built off `config.listFilters` — same source the old plain `<Select>` row
|
|
// read, just reshaped into FilterDefs. Falls back to a plain client-only
|
|
// Status filter for the 3 slugs with no server-side list filters at all.
|
|
const filterDefs: FilterDef[] = useMemo(() => {
|
|
const dateDef: FilterDef = {
|
|
key: "created",
|
|
label: "Registered",
|
|
type: "date",
|
|
secondary: true,
|
|
toParams: dateRangeParams("createdFrom", "createdTo"),
|
|
};
|
|
if (config?.listFilters?.length) {
|
|
return [
|
|
...config.listFilters.map((filter): FilterDef => ({
|
|
key: filter.key,
|
|
label: filter.label,
|
|
type: "enum",
|
|
multiple: false,
|
|
options: filter.dynamicOptions
|
|
? (dynamicOptions[filter.dynamicOptions] ?? [])
|
|
: (filter.options ?? []),
|
|
})),
|
|
dateDef,
|
|
];
|
|
}
|
|
const fallback = FALLBACK_STATUS_OPTIONS[slug];
|
|
return fallback
|
|
? [{ key: "status", label: "Status", type: "enum", multiple: false, options: fallback }, dateDef]
|
|
: [dateDef];
|
|
}, [config, dynamicOptions, slug]);
|
|
|
|
const controls = useFilters(filterDefs, { pageSize: 10 });
|
|
|
|
// 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.
|
|
// `controls.params` already carries every filter's mapped param name (status/
|
|
// currentYardId/wagonTypeId/… default to `{key: value}`, "created" maps to
|
|
// createdFrom/createdTo) plus search/page/pageSize — it IS the paged filter
|
|
// object; the unpaged one is the same minus pagination and the date range
|
|
// (which stays client-only for the non-server-paged slugs, see below).
|
|
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
|
if (!SERVER_FILTERED_SLUGS.includes(slug)) return undefined;
|
|
const { page: _page, pageSize: _pageSize, createdFrom: _cf, createdTo: _ct, ...rest } = controls.params;
|
|
return rest as FleetListFilters;
|
|
}, [slug, controls.params]);
|
|
|
|
const pagedFilters = useMemo(
|
|
(): FleetListFilters => controls.params as unknown as FleetListFilters,
|
|
[controls.params],
|
|
);
|
|
|
|
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());
|
|
|
|
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;
|
|
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.
|
|
const created = controls.values.created;
|
|
if (created && !matchesDayRange(record.createdAt, created.v[0]?.slice(0, 10) ?? null, created.v[1]?.slice(0, 10) ?? null)) {
|
|
return false;
|
|
}
|
|
// Every other filter (status/yard/wagon type/…) was already applied
|
|
// server-side for these slugs — re-checking here against a plain field
|
|
// equality would be wrong for one (a wagon's "trainNumber" filter
|
|
// matches either of two DIFFERENT columns server-side, not one).
|
|
if (usesServerListFilters) return true;
|
|
const status = controls.values.status;
|
|
if (status && String(record.status ?? "") !== status.v[0]) return false;
|
|
const term = controls.searchText.trim().toLowerCase();
|
|
if (!term) return true;
|
|
return config.searchKeys.some((key) =>
|
|
String(record[key] ?? "")
|
|
.toLowerCase()
|
|
.includes(term),
|
|
);
|
|
});
|
|
}, [allRows, config, usesServerListFilters, controls.values, controls.searchText, 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 / controls.pageSize));
|
|
const pagedRows = useMemo(() => {
|
|
if (serverPaged) return filteredRows;
|
|
const start = (controls.page - 1) * controls.pageSize;
|
|
return filteredRows.slice(start, start + controls.pageSize);
|
|
}, [filteredRows, controls.page, controls.pageSize, serverPaged]);
|
|
|
|
// Same {pagination, tableOptions} shape DataTable takes directly; FleetCardGrid
|
|
// (not a DataTable) just needs the raw pieces out of it below.
|
|
const { pagination: dtPagination, tableOptions: dtTableOptions } = controls.tableProps(totalCount);
|
|
|
|
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)" }}>
|
|
<FilterBar
|
|
defs={filterDefs}
|
|
controls={controls}
|
|
searchPlaceholder={config.searchPlaceholder}
|
|
showSearch={config.supportsSearch}
|
|
viewId={`fleet-${slug}`}
|
|
>
|
|
<SegmentedControl
|
|
value={viewMode}
|
|
onChange={(value) => setViewMode(value as FleetViewMode)}
|
|
size="sm"
|
|
radius="lg"
|
|
data={[
|
|
{
|
|
value: "table",
|
|
label: (
|
|
<Group gap={6} justify="center" wrap="nowrap">
|
|
<Table2 size={14} />
|
|
<span>Table</span>
|
|
</Group>
|
|
),
|
|
},
|
|
{
|
|
value: "cards",
|
|
label: (
|
|
<Group gap={6} justify="center" wrap="nowrap">
|
|
<LayoutGrid size={14} />
|
|
<span>Cards</span>
|
|
</Group>
|
|
),
|
|
},
|
|
]}
|
|
styles={{ root: { background: "var(--mantine-color-gray-1)" } }}
|
|
/>
|
|
</FilterBar>
|
|
</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={dtPagination}
|
|
tableOptions={dtTableOptions}
|
|
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={{ pageIndex: controls.page - 1, pageSize: controls.pageSize }}
|
|
pageCount={pageCount}
|
|
totalCount={totalCount}
|
|
onPaginationChange={dtTableOptions!.onPaginationChange!}
|
|
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;
|