mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 04:20:55 +00:00
281 lines
8.6 KiB
TypeScript
281 lines
8.6 KiB
TypeScript
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>
|
||
{log?.startedByName || log?.endedByName ? (
|
||
<Tooltip
|
||
label={[
|
||
log?.startedByName ? `Started by ${log.startedByName}` : null,
|
||
log?.endedByName ? `Ended by ${log.endedByName}` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ")}
|
||
>
|
||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||
{log?.endedByName ?? log?.startedByName}
|
||
</Badge>
|
||
</Tooltip>
|
||
) : null}
|
||
<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>
|
||
);
|
||
}
|