mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
[200~feat: add CustomsPaymentsCard and PaymentsTab components for handling customs payments and payment summaries
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import { AlertTriangle, Lock, MapPin } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { api } from "@/services/api";
|
||||
import type { ScheduleWagonYardRow } from "@/services/trainBuilder.service";
|
||||
|
||||
/**
|
||||
* Schedule yards tab: where THIS departure plans to board each consist wagon,
|
||||
* side by side with where the wagon physically stands (the train builder's
|
||||
* truth). Booking capacity per origin reads the plan; dispatch refuses to
|
||||
* leave until plan and physical yards agree. Edits are queued locally and
|
||||
* saved in one PATCH.
|
||||
*/
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scheduleId: string;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleWagonYardPanel({ scheduleId, canEdit }: Props) {
|
||||
const { toast } = useToast();
|
||||
const query = useQuery(
|
||||
api.trainScheduling.scheduleWagonYards.queryOptions({ input: { scheduleId } }),
|
||||
);
|
||||
const save = useMutation(api.trainScheduling.updateScheduleWagonYards.mutationOptions());
|
||||
const data = query.data;
|
||||
|
||||
/** wagonId → yardId queued but not yet saved. */
|
||||
const [pending, setPending] = useState<Record<string, string>>({});
|
||||
const [bulkType, setBulkType] = useState<string | null>(null);
|
||||
const [bulkFrom, setBulkFrom] = useState<string | null>(null);
|
||||
const [bulkTo, setBulkTo] = useState<string | null>(null);
|
||||
const [bulkCount, setBulkCount] = useState<number | string>(1);
|
||||
|
||||
const editable = Boolean(canEdit && data?.editable);
|
||||
const pickupStops = useMemo(() => (data?.stops ?? []).filter((s) => s.pickup), [data]);
|
||||
const yardOptions = pickupStops.map((s) => ({ value: s.yardId, label: s.label }));
|
||||
const yardLabel = (id: string | null) =>
|
||||
(data?.stops ?? []).find((s) => s.yardId === id)?.label ??
|
||||
data?.wagons.find((w) => w.plannedYardId === id)?.plannedYardLabel ??
|
||||
data?.wagons.find((w) => w.physicalYardId === id)?.physicalYardLabel ??
|
||||
id ??
|
||||
"—";
|
||||
|
||||
const effectiveYard = (w: ScheduleWagonYardRow) => pending[w.id] ?? w.plannedYardId;
|
||||
|
||||
const perStop = useMemo(
|
||||
() =>
|
||||
(data?.stops ?? []).map((s) => ({
|
||||
...s,
|
||||
planned: (data?.wagons ?? []).filter((w) => (pending[w.id] ?? w.plannedYardId) === s.yardId)
|
||||
.length,
|
||||
})),
|
||||
[data, pending],
|
||||
);
|
||||
const typeOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const w of data?.wagons ?? []) seen.set(w.wagonType.id, w.wagonType.code);
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [data]);
|
||||
|
||||
const pendingCount = Object.keys(pending).length;
|
||||
|
||||
const queueBulk = () => {
|
||||
if (!data || !bulkFrom || !bulkTo || bulkFrom === bulkTo) return;
|
||||
const n = Number(bulkCount) || 0;
|
||||
const picked = data.wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
!w.locked &&
|
||||
effectiveYard(w) === bulkFrom &&
|
||||
(!bulkType || w.wagonType.id === bulkType),
|
||||
)
|
||||
.slice(0, n);
|
||||
if (!picked.length) {
|
||||
toast({ title: "No free wagons match", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const w of picked) {
|
||||
if (w.plannedYardId === bulkTo) delete next[w.id];
|
||||
else next[w.id] = bulkTo;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!pendingCount) return;
|
||||
try {
|
||||
const result = await save.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
moves: Object.entries(pending).map(([wagonId, yardId]) => ({ wagonId, yardId })),
|
||||
},
|
||||
});
|
||||
setPending({});
|
||||
toast({
|
||||
title: `Schedule yards updated — ${pendingCount} wagon(s) re-planned`,
|
||||
description: result.warnings.length ? result.warnings.join(" ") : undefined,
|
||||
variant: result.warnings.length ? "destructive" : undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update the schedule's wagon yards"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (query.isLoading) return <Loader size="sm" />;
|
||||
if (query.isError || !data) {
|
||||
return (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||||
{parseError(
|
||||
query.error,
|
||||
"This schedule has no wagon yard plan (not created from a built train).",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" icon={<MapPin size={16} />} variant="light">
|
||||
<b>Planned</b> = where this departure boards the wagon (what customers can book per
|
||||
origin). <b>Physical</b> = where the wagon stands now (train builder). Dispatch is blocked
|
||||
until every wagon stands at its planned yard.
|
||||
{data.misaligned > 0 ? (
|
||||
<Text component="span" c="orange" fw={600}>
|
||||
{" "}
|
||||
{data.misaligned} wagon(s) currently misaligned.
|
||||
</Text>
|
||||
) : null}
|
||||
</Alert>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, md: Math.min(5, Math.max(2, perStop.length)) }}>
|
||||
{perStop.map((s) => (
|
||||
<Paper key={s.yardId} withBorder p="sm" radius="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fw={600} size="sm">
|
||||
{s.label}
|
||||
</Text>
|
||||
{!s.pickup ? (
|
||||
<Badge size="xs" color="gray" variant="light">
|
||||
destination
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge color="edr-green" variant="filled">
|
||||
Planned {s.planned}
|
||||
</Badge>
|
||||
<Badge color={s.physical === s.planned ? "gray" : "orange"} variant="light">
|
||||
Physical {s.physical}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{editable ? (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Group align="end" gap="sm" wrap="wrap">
|
||||
<NumberInput
|
||||
label="Move"
|
||||
min={1}
|
||||
max={data.wagons.length}
|
||||
value={bulkCount}
|
||||
onChange={setBulkCount}
|
||||
w={90}
|
||||
/>
|
||||
<Select
|
||||
label="Wagon type"
|
||||
placeholder="Any"
|
||||
clearable
|
||||
data={typeOptions}
|
||||
value={bulkType}
|
||||
onChange={setBulkType}
|
||||
w={140}
|
||||
/>
|
||||
<Select label="From" data={yardOptions} value={bulkFrom} onChange={setBulkFrom} w={170} />
|
||||
<Select label="To" data={yardOptions} value={bulkTo} onChange={setBulkTo} w={170} />
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={queueBulk}
|
||||
disabled={!bulkFrom || !bulkTo || bulkFrom === bulkTo}
|
||||
>
|
||||
Queue
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Physical yard</Table.Th>
|
||||
<Table.Th>Planned yard (this schedule)</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.wagons.map((w) => {
|
||||
const planned = effectiveYard(w);
|
||||
const changed = w.id in pending;
|
||||
return (
|
||||
<Table.Tr key={w.id} bg={changed ? "var(--mantine-color-yellow-light)" : undefined}>
|
||||
<Table.Td>{w.sequenceNumber ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{w.wagonNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{w.wagonType.code}</Table.Td>
|
||||
<Table.Td>{w.physicalYardLabel ?? "No yard"}</Table.Td>
|
||||
<Table.Td>
|
||||
{editable && !w.locked ? (
|
||||
<Select
|
||||
size="xs"
|
||||
data={yardOptions}
|
||||
value={planned}
|
||||
onChange={(v) =>
|
||||
setPending((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!v || v === w.plannedYardId) delete next[w.id];
|
||||
else next[w.id] = v;
|
||||
return next;
|
||||
})
|
||||
}
|
||||
w={180}
|
||||
/>
|
||||
) : (
|
||||
<Group gap={4}>
|
||||
<Text size="sm">{yardLabel(planned)}</Text>
|
||||
{w.locked ? (
|
||||
<Tooltip label={w.lockReason ?? "Locked"}>
|
||||
<Lock size={14} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{planned === w.physicalYardId ? (
|
||||
<Badge color="teal" variant="light" size="sm">
|
||||
Aligned
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
Needs move
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{editable ? (
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="dimmed">
|
||||
{pendingCount} pending change(s)
|
||||
</Text>
|
||||
<Button variant="default" onClick={() => setPending({})} disabled={!pendingCount}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
loading={save.isPending}
|
||||
disabled={!pendingCount}
|
||||
>
|
||||
Save plan
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user