mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
215 lines
6.9 KiB
TypeScript
215 lines
6.9 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
||
import { History, User } from "lucide-react";
|
||
import {
|
||
Avatar,
|
||
Badge,
|
||
Group,
|
||
Loader,
|
||
Stack,
|
||
Text,
|
||
Timeline,
|
||
Tooltip,
|
||
} from "@mantine/core";
|
||
import type { Freight } from "@edr/types";
|
||
|
||
import { contractsService } from "@/services/contracts.service";
|
||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||
|
||
interface ContractRevisionTimelineProps {
|
||
contractId: string;
|
||
/** Rendered as a plain block instead of a SectionCard (own-tab layout). */
|
||
bare?: boolean;
|
||
}
|
||
|
||
type Change = Freight.IContractDocumentChange;
|
||
|
||
/** Badge colour + verb per change kind, so a revision reads at a glance. */
|
||
const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> = {
|
||
ARTICLE_ADDED: { color: "green", label: "Added" },
|
||
ARTICLE_REMOVED: { color: "red", label: "Removed" },
|
||
ARTICLE_RENAMED: { color: "violet", label: "Renamed" },
|
||
ARTICLE_BODY_CHANGED: { color: "blue", label: "Edited" },
|
||
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
|
||
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
|
||
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
|
||
FIELD_CHANGED: { color: "orange", label: "Field" },
|
||
};
|
||
|
||
/** Initials for the actor avatar — "Abenezer Haile" → "AH". */
|
||
function initials(name: string): string {
|
||
return name
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.slice(0, 2)
|
||
.map((part) => part[0]?.toUpperCase() ?? "")
|
||
.join("");
|
||
}
|
||
|
||
/** Role slugs arrive like "-marketing-director-"; render them readably. */
|
||
function prettyRole(role: string): string {
|
||
const cleaned = role.replace(/^-+|-+$/g, "").replace(/[-_]+/g, " ").trim();
|
||
if (!cleaned) return role;
|
||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
|
||
}
|
||
|
||
/** What the change applies to — an article title, or the document itself. */
|
||
function changeSubject(change: Change): string {
|
||
switch (change.kind) {
|
||
case "DOCUMENT_TITLE_CHANGED":
|
||
return change.fromTitle
|
||
? `“${change.fromTitle}” → “${change.title}”`
|
||
: change.title;
|
||
case "WHEREAS_CHANGED": {
|
||
const parts: string[] = [];
|
||
if (change.added) parts.push(`+${change.added}`);
|
||
if (change.removed) parts.push(`−${change.removed}`);
|
||
return parts.join(" ") || "changed";
|
||
}
|
||
case "ARTICLE_RENAMED":
|
||
return `“${change.fromTitle}” → “${change.title}”`;
|
||
case "ARTICLE_REORDERED":
|
||
return `${change.title} (${change.fromOrder} → ${change.toOrder})`;
|
||
case "FIELD_CHANGED":
|
||
return `${change.label}: ${change.from ?? "—"} → ${change.to ?? "—"}`;
|
||
default:
|
||
return change.title;
|
||
}
|
||
}
|
||
|
||
function formatWhen(iso: string): string {
|
||
const date = new Date(iso);
|
||
return date.toLocaleString(undefined, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
});
|
||
}
|
||
|
||
/** "3 hours ago" — the at-a-glance read; the exact stamp sits beside it. */
|
||
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";
|
||
}
|
||
|
||
/**
|
||
* Audit trail of edits to the contract document. The document stays editable
|
||
* through the approval chain, so this is the record of who changed what.
|
||
*/
|
||
export function ContractRevisionTimeline({
|
||
contractId,
|
||
bare = false,
|
||
}: ContractRevisionTimelineProps) {
|
||
const { data: revisions, isLoading } = useQuery({
|
||
queryKey: ["contracts", contractId, "document-revisions"],
|
||
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
|
||
});
|
||
|
||
const body = isLoading ? (
|
||
<Group gap="xs">
|
||
<Loader size="xs" />
|
||
<Text size="sm" c="dimmed">
|
||
Loading history…
|
||
</Text>
|
||
</Group>
|
||
) : !revisions?.length ? (
|
||
<Stack gap={4} align="center" py="xl">
|
||
<History size={26} color="var(--mantine-color-gray-5)" />
|
||
<Text size="sm" fw={500}>
|
||
No edits recorded yet
|
||
</Text>
|
||
<Text size="xs" c="dimmed" ta="center" maw={420}>
|
||
Every change to this contract — its articles during review and approval,
|
||
or its details while the customer can still edit it — is logged here
|
||
with who made it and when.
|
||
</Text>
|
||
</Stack>
|
||
) : (
|
||
<Timeline
|
||
active={revisions.length}
|
||
bulletSize={28}
|
||
lineWidth={2}
|
||
color="edr-green"
|
||
>
|
||
{revisions.map((revision) => {
|
||
const who = revision.actorName?.trim();
|
||
const role = revision.actorRole ? prettyRole(revision.actorRole) : null;
|
||
return (
|
||
<Timeline.Item
|
||
key={revision.id}
|
||
bullet={
|
||
<Avatar size={26} radius="xl" color="edr-green" variant="light">
|
||
<Text size="10px" fw={700}>
|
||
{who ? initials(who) : <User size={13} />}
|
||
</Text>
|
||
</Avatar>
|
||
}
|
||
title={
|
||
<Group gap="xs" wrap="wrap" align="baseline">
|
||
<Text size="sm" fw={600}>
|
||
{who ?? role ?? "Unknown user"}
|
||
</Text>
|
||
{role && who && (
|
||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||
{role}
|
||
</Badge>
|
||
)}
|
||
<Tooltip label={formatWhen(revision.createdAt)} withArrow>
|
||
<Text size="xs" c="dimmed">
|
||
{formatAgo(revision.createdAt)}
|
||
</Text>
|
||
</Tooltip>
|
||
</Group>
|
||
}
|
||
>
|
||
<Stack gap={6} mt={6} pb="xs">
|
||
{revision.summary && (
|
||
<Text size="xs" c="dimmed">
|
||
{revision.summary}
|
||
</Text>
|
||
)}
|
||
{revision.changes.map((change, index) => {
|
||
const style = CHANGE_STYLES[change.kind];
|
||
return (
|
||
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
|
||
<Badge
|
||
size="xs"
|
||
variant="light"
|
||
color={style?.color ?? "gray"}
|
||
style={{ flexShrink: 0 }}
|
||
>
|
||
{style?.label ?? change.kind}
|
||
</Badge>
|
||
<Text size="xs" style={{ lineHeight: 1.5 }}>
|
||
{changeSubject(change)}
|
||
</Text>
|
||
</Group>
|
||
);
|
||
})}
|
||
</Stack>
|
||
</Timeline.Item>
|
||
);
|
||
})}
|
||
</Timeline>
|
||
);
|
||
|
||
if (bare) return body;
|
||
return (
|
||
<SectionCard icon={History} title="Change history">
|
||
{body}
|
||
</SectionCard>
|
||
);
|
||
}
|