Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx
2026-08-13 13:46:46 +00:00

222 lines
6.6 KiB
TypeScript

import { useMemo } from "react";
import {
BadgeCheck,
CalendarClock,
Flame,
Send,
ShieldCheck,
FileSignature,
} from "lucide-react";
import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core";
import type { Freight } from "@edr/types";
import { formatDate } from "@/lib/format";
import {
CONTRACT_APPROVAL_ROLE_LABELS,
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
} from "@/lib/permissions";
interface ContractMilestonesTimelineProps {
contract: Freight.IContract;
}
const SIGNATURE_ROLE_LABELS: Record<Freight.ContractSignatureRole, string> = {
CUSTOMER: "Signed by customer",
STAFF: "Signed by EDR — line staff",
DIRECTOR: "Signed by EDR — director",
CEO: "Signed by EDR — CEO",
};
/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/** "3 hours ago" — the at-a-glance read. */
function formatAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, secondsPerUnit] of units) {
if (seconds >= secondsPerUnit) {
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
}
}
return "just now";
}
type MilestoneIcon = typeof Send;
interface Milestone {
key: string;
at: string;
title: string;
detail?: string;
color: string;
icon: MilestoneIcon;
}
/**
* The dated moments of a contract's life — submission, hazardous approval,
* final approval, both parties' signatures, full execution — read straight off
* the contract and its already-loaded approvalSteps/signatures (no extra
* fetch). Sits above the document edit history on the History tab.
*/
export function ContractMilestonesTimeline({
contract,
}: ContractMilestonesTimelineProps) {
const milestones = useMemo<Milestone[]>(() => {
const items: Milestone[] = [];
// A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing
// to date. submittedAt is only tracked going forward; a contract that
// reached SUBMITTED before that column existed falls back to createdAt.
const submittedAt =
contract.submittedAt ??
(contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT"
? contract.createdAt
: null);
if (submittedAt) {
items.push({
key: "submitted",
at: submittedAt,
title: "Submitted for review",
color: "blue",
icon: Send,
});
}
// Every acted approval step, not just hazardous ones — this is the one
// place the approval-time record shows up in the page's main content
// (the sidebar's ContractApprovalStepsCard has the same times, but only
// there, and only while the chain is still actionable).
for (const step of contract.approvalSteps ?? []) {
if (!step.actedAt) continue;
const hazard = step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
items.push({
key: `step-${step.id}`,
at: step.actedAt,
title: `${CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole} ${step.status === "REJECTED" ? "rejected" : "approved"}`,
detail: step.note ?? undefined,
color: step.status === "REJECTED" ? "red" : hazard ? "orange" : "edr-green",
icon: hazard ? Flame : ShieldCheck,
});
}
if (contract.contractGeneratedAt) {
items.push({
key: "approved",
at: contract.contractGeneratedAt,
title: "Contract approved",
detail: "Every approval step cleared and the document was generated",
color: "edr-green",
icon: ShieldCheck,
});
}
for (const sig of contract.signatures ?? []) {
items.push({
key: `signature-${sig.id}`,
at: sig.signedAt,
title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`,
detail: sig.signerDisplayName,
color: "grape",
icon: FileSignature,
});
}
if (contract.fullyExecutedAt) {
items.push({
key: "executed",
at: contract.fullyExecutedAt,
title: "Fully executed",
detail: "Both parties have signed",
color: "edr-green",
icon: BadgeCheck,
});
}
return items.sort(
(a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(),
);
}, [contract]);
const hasValidity = contract.contractValidFrom && contract.contractValidUntil;
if (milestones.length === 0 && !hasValidity) {
return (
<Text size="sm" c="dimmed" ta="center" py="lg">
No dated milestones recorded yet.
</Text>
);
}
return (
<Stack gap="md">
{hasValidity && (
<Group
gap="xs"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
width: "fit-content",
}}
>
<CalendarClock size={16} color="var(--mantine-color-gray-6)" />
<Text size="sm">
Valid <strong>{formatDate(contract.contractValidFrom!)}</strong>
{" → "}
<strong>{formatDate(contract.contractValidUntil!)}</strong>
</Text>
</Group>
)}
{milestones.length > 0 && (
<Timeline active={milestones.length} bulletSize={26} lineWidth={2} color="edr-green">
{milestones.map((m) => {
const Icon = m.icon;
return (
<Timeline.Item
key={m.key}
bullet={<Icon size={13} />}
color={m.color}
title={
<Group gap="xs" wrap="wrap" align="baseline">
<Text size="sm" fw={600}>
{m.title}
</Text>
<Tooltip label={formatWhen(m.at)} withArrow>
<Text size="xs" c="dimmed">
{formatAgo(m.at)}
</Text>
</Tooltip>
</Group>
}
>
{m.detail && (
<Text size="xs" c="dimmed" mt={2}>
{m.detail}
</Text>
)}
</Timeline.Item>
);
})}
</Timeline>
)}
</Stack>
);
}