feat: enhance train scheduling and contract management features

- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -0,0 +1,266 @@
import {
ActionIcon,
Badge,
Button,
Group,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation } from "@tanstack/react-query";
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { StationWorkPhaseLog } 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 fmtTime = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
const from = new Date(fromIso).getTime();
const to = toIso ? new Date(toIso).getTime() : Date.now();
const mins = Math.max(0, Math.round((to - from) / 60_000));
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
/** Pencil-popover to correct an already-recorded start/end timestamp. */
function EditTimeButton({
label,
value,
disabled,
disabledReason,
minDate,
maxDate,
onSave,
saving,
}: {
label: string;
value: string;
disabled: boolean;
disabledReason: string;
minDate?: Date;
maxDate?: Date;
onSave: (at: Date) => void;
saving: boolean;
}) {
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState<Date | null>(null);
useEffect(() => {
if (opened) setDraft(new Date(value));
}, [opened, value]);
return (
<Popover opened={opened} onChange={setOpened} withArrow shadow="md" position="bottom">
<Popover.Target>
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
disabled={disabled}
onClick={() => setOpened((o) => !o)}
>
<Pencil size={12} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<DateTimePicker
label={`Correct ${label} time`}
value={draft}
onChange={(v) => setDraft(v ? new Date(v) : null)}
minDate={minDate}
maxDate={maxDate ?? new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={280}
/>
<Group justify="flex-end" gap="xs">
<Button size="compact-xs" variant="default" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
size="compact-xs"
loading={saving}
disabled={!draft}
onClick={() => {
if (draft) {
onSave(draft);
setOpened(false);
}
}}
>
Save
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
/**
* Start/End buttons + elapsed time for one station's loading OR unloading
* window. Booking load/unload at the yard is server-gated on the window having
* been started, so these buttons come first in the operator's flow. Each of
* the four buttons (start/end × loading/unloading) is its own permission, and
* the pencil edits a recorded time under the same permission that set it.
*/
export function StationWorkControls({
scheduleId,
yardId,
phase,
log,
}: {
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
log?: StationWorkPhaseLog | null;
}) {
const { user } = useAuth();
const { toast } = useToast();
const canStart = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingStart
: FREIGHT_PERMS.trainScheduling.unloadingStart,
);
const canEnd = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingEnd
: FREIGHT_PERMS.trainScheduling.unloadingEnd,
);
const record = useMutation(api.trainScheduling.recordStationWork.mutationOptions());
// Re-render each minute so the running elapsed time ticks while unended.
const [, setTick] = useState(0);
useEffect(() => {
if (!log?.startedAt || log?.endedAt) return;
const t = setInterval(() => setTick((n) => n + 1), 60_000);
return () => clearInterval(t);
}, [log?.startedAt, log?.endedAt]);
const doRecord = (edge: "start" | "end", at?: Date) => {
record.mutate(
{ scheduleId, yardId, phase, edge, ...(at ? { at: at.toISOString() } : {}) },
{
onSuccess: () =>
toast({
title: `${phase === "loading" ? "Loading" : "Unloading"} ${edge} recorded`,
}),
onError: (err) =>
toast({
title: `Could not record ${phase} ${edge}`,
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const title = phase === "loading" ? "Loading" : "Unloading";
const started = Boolean(log?.startedAt);
const ended = Boolean(log?.endedAt);
return (
<Group gap="sm" wrap="wrap" align="center">
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
{title}
{ended ? " done" : started ? " in progress" : " not started"}
</Badge>
{!started ? (
<Tooltip
label={
canStart
? `Record the moment ${phase} work begins at this station`
: `You don't have permission to start ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PlayCircle size={14} />}
disabled={!canStart}
loading={record.isPending}
onClick={() => doRecord("start")}
>
Start {phase}
</Button>
</Tooltip>
) : (
<>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}
disabled={!canStart}
disabledReason={`You don't have permission to edit the ${phase} start`}
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
onSave={(at) => doRecord("start", at)}
saving={record.isPending}
/>
{ended ? (
<EditTimeButton
label={`${phase} end`}
value={log!.endedAt!}
disabled={!canEnd}
disabledReason={`You don't have permission to edit the ${phase} end`}
minDate={new Date(log!.startedAt!)}
onSave={(at) => doRecord("end", at)}
saving={record.isPending}
/>
) : null}
</Group>
{!ended ? (
<Tooltip
label={
canEnd
? `Record the moment ${phase} work is finished at this station`
: `You don't have permission to end ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="orange"
leftSection={<StopCircle size={14} />}
disabled={!canEnd}
loading={record.isPending}
onClick={() => doRecord("end")}
>
End {phase}
</Button>
</Tooltip>
) : null}
</>
)}
</Group>
);
}