mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 05:43:39 +00:00
changes
This commit is contained in:
@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
|
||||
EXPIRED: "red",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "cyan",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -630,6 +630,20 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile && (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file!.id, true)}
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -727,9 +727,9 @@ function ImportT1UploadStep({
|
||||
);
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
|
||||
// closes/accepts the T1.
|
||||
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
</Alert>
|
||||
) : uploaded.length === 0 && !canUpload ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
|
||||
@@ -177,6 +177,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
APPROVED: "cyan",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "blue",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -33,7 +33,9 @@ const FleetRecordActions = ({
|
||||
const isVehicle = config.slug === "vehicles";
|
||||
const showHistory =
|
||||
Boolean(onHistory) &&
|
||||
(config.slug === "drivers" || config.slug === "vehicles");
|
||||
(config.slug === "drivers" ||
|
||||
config.slug === "vehicles" ||
|
||||
config.slug === "wagons");
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import type { WagonMovementRecord } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonMovementHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: FleetRecord | null;
|
||||
}
|
||||
|
||||
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Chip style per wagon_movements ledger kind. */
|
||||
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
|
||||
LOADED: {
|
||||
label: "Loaded leg",
|
||||
color: "edr-green",
|
||||
icon: <PackageCheck size={14} />,
|
||||
},
|
||||
EMPTY_REPOSITION: {
|
||||
label: "Empty reposition",
|
||||
color: "blue",
|
||||
icon: <TrainFront size={14} />,
|
||||
},
|
||||
MANUAL: {
|
||||
label: "Manual move",
|
||||
color: "orange",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
const yardLabel = (
|
||||
yard: { label?: string; code?: string } | null | undefined,
|
||||
yardId: string | null,
|
||||
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
|
||||
|
||||
const fmt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Movement ledger for one wagon: every relocation between yards — booking legs,
|
||||
* empty reposition rides, and manual staff corrections — newest first.
|
||||
*/
|
||||
const WagonMovementHistoryModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
record,
|
||||
}: WagonMovementHistoryModalProps) => {
|
||||
const r = asObj(record);
|
||||
const id = r.id ? String(r.id) : "";
|
||||
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
api.wagons.movements.queryOptions({
|
||||
input: { id },
|
||||
enabled: opened && Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const movements: WagonMovementRecord[] = data ?? [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
|
||||
radius="lg"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : movements.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No movements recorded yet. Every yard-to-yard move appears here — a
|
||||
booking's loaded leg, an empty reposition ride, or a manual correction.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
|
||||
{movements.map((movement) => {
|
||||
const meta = KIND_META[movement.kind] ?? {
|
||||
label: movement.kind,
|
||||
color: "gray",
|
||||
icon: <TrainFront size={14} />,
|
||||
};
|
||||
const from = yardLabel(movement.fromYard, movement.fromYardId);
|
||||
const to = yardLabel(movement.toYard, movement.toYardId);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={movement.id}
|
||||
bullet={meta.icon}
|
||||
title={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{movement.note && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{movement.note}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{fmt(movement.occurredAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonMovementHistoryModal;
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCell({ row }: { row: YardWorkBookingRow }) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkTable({
|
||||
rows,
|
||||
side,
|
||||
trainHere,
|
||||
onLoad,
|
||||
onUnload,
|
||||
pendingBookingId,
|
||||
}: {
|
||||
rows: YardWorkBookingRow[];
|
||||
side: "load" | "unload";
|
||||
trainHere: boolean;
|
||||
onLoad: (bookingId: string) => void;
|
||||
onUnload: (bookingId: string) => void;
|
||||
pendingBookingId: string | null;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{side === "load" ? "No bookings board here." : "No bookings alight here."}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
|
||||
const canAct = side === "load" ? row.canLoad : row.canUnload;
|
||||
return (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<BookingCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{timestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(timestamp)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{side === "load" ? (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo loaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.loadedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onLoad(row.id)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo unloaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.arrivedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onUnload(row.id)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard load/unload worklist for one schedule — every trade direction. Each
|
||||
* booking boards at its origin yard and alights at its destination yard; the
|
||||
* operator confirms both while the train's last recorded checkpoint is at that
|
||||
* yard (the server validates the position). Unloading stamps the booking's own
|
||||
* arrival — ARRIVED for import/export, COMPLETED for intercity.
|
||||
*/
|
||||
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Cargo loaded" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Load failed",
|
||||
description: parseError(err, "Could not confirm loading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadScheduleBooking.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
void invalidate();
|
||||
toast({
|
||||
title:
|
||||
result.status === "COMPLETED"
|
||||
? "Cargo unloaded — booking completed"
|
||||
: "Cargo unloaded — booking arrived",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Unload failed",
|
||||
description: parseError(err, "Could not confirm unloading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const data = yardWorkQuery.data;
|
||||
const yards: YardWorkYard[] = data?.yards ?? [];
|
||||
const trainAtYardId = data?.trainAtYardId ?? null;
|
||||
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
|
||||
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<MapPin size={18} />
|
||||
<Text fw={700}>Yard load / unload</Text>
|
||||
</Group>
|
||||
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading yard worklists…
|
||||
</Text>
|
||||
</Group>
|
||||
) : yardWorkQuery.isError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
|
||||
</Alert>
|
||||
) : yards.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings are assigned to this schedule yet.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
What boards and alights at each stop. Confirm loading at a booking's
|
||||
origin and unloading at its destination while the train is at that
|
||||
yard — unloading stamps the booking's own arrival, even before the
|
||||
train's final stop.
|
||||
</Text>
|
||||
{yards.map((yard, index) => {
|
||||
const trainHere = trainAtYardId === yard.yardId;
|
||||
return (
|
||||
<Stack key={yard.yardId} gap="sm">
|
||||
{index > 0 && <Divider />}
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{yard.yard}</Text>
|
||||
{trainHere && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={12} />}
|
||||
>
|
||||
Train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Board here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toLoad}
|
||||
side="load"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingLoadId}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Alight here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toUnload}
|
||||
side="unload"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingUnloadId}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user