Files
edr-platform/apps/edr-freight-web/backoffice/src/components/contracts/ContractRevisionTimeline.tsx

215 lines
6.9 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 { 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>
);
}