Files
edr-platform/apps/edr-freight-web/backoffice/src/components/trainScheduling/StationWorkControls.tsx

281 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}