Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceMilestoneTimeline.tsx
2026-06-26 23:24:48 +00:00

195 lines
6.2 KiB
TypeScript

import { useState } from "react";
import {
Badge,
Box,
Button,
Group,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { Check, Circle, Clock, MinusCircle } from "lucide-react";
import type { Freight } from "@edr/types";
export interface ClearanceMilestoneTimelineProps {
milestones: Freight.IClearanceMilestone[];
/** Complete a milestone by code (omit to render read-only). */
onComplete?: (code: string, note?: string) => void;
/** True while a complete mutation is in flight. */
busy?: boolean;
}
const STATUS_META: Record<
Freight.MilestoneStatus,
{ color: string; label: string }
> = {
COMPLETED: { color: "edr-green", label: "Completed" },
PENDING: { color: "gray", label: "Pending" },
SKIPPED: { color: "gray", label: "Skipped" },
};
/** Vertical timeline of GL clearance milestones with inline complete actions. */
export function ClearanceMilestoneTimeline({
milestones,
onComplete,
busy,
}: ClearanceMilestoneTimelineProps) {
const [openNote, setOpenNote] = useState<Record<string, boolean>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
const nextPending = sorted.find((m) => m.status === "PENDING");
if (sorted.length === 0) {
return (
<Text size="sm" c="dimmed">
No milestones for this shipment yet.
</Text>
);
}
return (
<Stack gap={0}>
{sorted.map((m, index) => {
const isLast = index === sorted.length - 1;
const meta = STATUS_META[m.status];
const isNext = nextPending?.id === m.id;
const Icon =
m.status === "COMPLETED"
? Check
: m.status === "SKIPPED"
? MinusCircle
: isNext
? Clock
: Circle;
return (
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
<ThemeIcon
variant={m.status === "COMPLETED" ? "filled" : "light"}
color={isNext ? "edr-green" : meta.color}
radius="xl"
size={30}
>
<Icon size={15} strokeWidth={2.2} />
</ThemeIcon>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
minHeight: 28,
background:
m.status === "COMPLETED"
? "var(--mantine-color-edr-green-4)"
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Stack>
<Box pb={isLast ? 0 : "md"} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{m.milestoneLabel}
</Text>
<Group gap={6} mt={2} wrap="nowrap">
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
{m.ownerRegion ? (
<Badge size="xs" variant="default">
{m.ownerRegion}
</Badge>
) : null}
</Group>
{m.note ? (
<Text size="xs" c="dimmed" mt={4}>
{m.note}
</Text>
) : null}
</Box>
{onComplete && m.status === "PENDING" && isNext ? (
!openNote[m.milestoneCode] ? (
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={13} />}
disabled={busy}
onClick={() =>
setOpenNote((o) => ({
...o,
[m.milestoneCode]: true,
}))
}
>
Complete
</Button>
) : null
) : null}
</Group>
{onComplete && openNote[m.milestoneCode] && (
<Box mt="xs">
<Textarea
placeholder="Optional note for this milestone…"
value={notes[m.milestoneCode] ?? ""}
onChange={(e) =>
setNotes((n) => ({
...n,
[m.milestoneCode]: e.currentTarget.value,
}))
}
autosize
minRows={2}
size="sm"
radius="md"
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="compact-xs"
variant="subtle"
color="gray"
disabled={busy}
onClick={() =>
setOpenNote((o) => ({
...o,
[m.milestoneCode]: false,
}))
}
>
Cancel
</Button>
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={13} />}
loading={busy}
onClick={() => {
onComplete(
m.milestoneCode,
notes[m.milestoneCode]?.trim() || undefined,
);
setOpenNote((o) => ({
...o,
[m.milestoneCode]: false,
}));
}}
>
Mark complete
</Button>
</Group>
</Box>
)}
</Box>
</Group>
);
})}
</Stack>
);
}