mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user