From 5fa012cad3ace27c4ddb1ae112f427820db83d58 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 26 Jul 2026 19:09:01 +0000 Subject: [PATCH] add contract hazard declaration, DO collection dates, and booking request currency migrations; implement article body diff component and integrate into contract revision timeline --- ...000000000-AddContractHazardDeclaration.ts} | 0 ... => 2970000000000-AddDoCollectionDates.ts} | 0 ...980000000000-AddBookingRequestCurrency.ts} | 0 .../contracts/ArticleBodyDiff.test.ts | 61 +++++++ .../components/contracts/ArticleBodyDiff.tsx | 168 ++++++++++++++++++ .../contracts/ContractActionsToolbar.tsx | 4 +- .../contracts/ContractDocumentEditorModal.tsx | 6 +- .../contracts/ContractRevisionTimeline.tsx | 24 ++- .../src/hooks/contracts/useContracts.ts | 3 + .../src/services/contracts.service.ts | 2 + 10 files changed, 262 insertions(+), 6 deletions(-) rename apps/edr-freight-api/src/migrations/{2920000000000-AddContractHazardDeclaration.ts => 2960000000000-AddContractHazardDeclaration.ts} (100%) rename apps/edr-freight-api/src/migrations/{2930000000000-AddDoCollectionDates.ts => 2970000000000-AddDoCollectionDates.ts} (100%) rename apps/edr-freight-api/src/migrations/{2940000000000-AddBookingRequestCurrency.ts => 2980000000000-AddBookingRequestCurrency.ts} (100%) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts similarity index 100% rename from apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts rename to apps/edr-freight-api/src/migrations/2960000000000-AddContractHazardDeclaration.ts diff --git a/apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts b/apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts similarity index 100% rename from apps/edr-freight-api/src/migrations/2930000000000-AddDoCollectionDates.ts rename to apps/edr-freight-api/src/migrations/2970000000000-AddDoCollectionDates.ts diff --git a/apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts b/apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts similarity index 100% rename from apps/edr-freight-api/src/migrations/2940000000000-AddBookingRequestCurrency.ts rename to apps/edr-freight-api/src/migrations/2980000000000-AddBookingRequestCurrency.ts diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts new file mode 100644 index 000000000..64c817b6b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { diffWords } from "./ArticleBodyDiff"; + +/** Rebuild each side from the token stream — the diff must lose nothing. */ +const rebuild = ( + tokens: ReturnType, + side: "before" | "after", +): string => + tokens + .filter((t) => + side === "before" ? t.op !== "added" : t.op !== "removed", + ) + .map((t) => t.text) + .join(""); + +describe("diffWords", () => { + it("marks only the words that actually changed", () => { + const tokens = diffWords( + "The carrier shall deliver within 30 days.", + "The carrier shall deliver within 45 days.", + ); + + expect(tokens.filter((t) => t.op === "removed").map((t) => t.text)).toEqual([ + "30", + ]); + expect(tokens.filter((t) => t.op === "added").map((t) => t.text)).toEqual([ + "45", + ]); + }); + + it("reconstructs both sides losslessly, whitespace included", () => { + const before = "Payment is due\nwithin ten (10) working days."; + const after = "Payment is due\nwithin five (5) working days of invoice."; + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); + + it("reports nothing changed for identical text", () => { + const tokens = diffWords("Same clause.", "Same clause."); + expect(tokens.every((t) => t.op === "same")).toBe(true); + }); + + it("handles a body being emptied or written from scratch", () => { + expect(rebuild(diffWords("Some clause.", ""), "after")).toBe(""); + expect(rebuild(diffWords("", "Brand new clause."), "before")).toBe(""); + }); + + it("falls back to a whole-block replace on pathological input", () => { + // Past MAX_TOKENS the LCS table is skipped; the change must still be + // reported truthfully rather than silently dropped. + const before = Array.from({ length: 2000 }, (_, i) => `a${i}`).join(" "); + const after = Array.from({ length: 2000 }, (_, i) => `b${i}`).join(" "); + const tokens = diffWords(before, after); + + expect(rebuild(tokens, "before")).toBe(before); + expect(rebuild(tokens, "after")).toBe(after); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx new file mode 100644 index 000000000..1f3145390 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ArticleBodyDiff.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from "react"; +import { Box, Button, Group, Text } from "@mantine/core"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +type Op = "same" | "added" | "removed"; +interface Token { + op: Op; + text: string; +} + +/** Split on whitespace but KEEP it, so rebuilt text preserves its spacing. */ +function tokenize(text: string): string[] { + return text.split(/(\s+)/).filter((t) => t !== ""); +} + +/** + * Word-level diff via the classic LCS table. + * + * ponytail: O(n·m) time and memory over word counts. Contract articles are + * paragraphs (hundreds of words), so this is microseconds; the guard below + * bails to a whole-block replace if an article ever gets pathological. Swap in + * a real diff library only if that guard starts firing. + */ +const MAX_TOKENS = 1200; + +export function diffWords(before: string, after: string): Token[] { + const a = tokenize(before); + const b = tokenize(after); + + if (a.length > MAX_TOKENS || b.length > MAX_TOKENS) { + return [ + { op: "removed", text: before }, + { op: "added", text: after }, + ]; + } + + // lcs[i][j] = length of the longest common subsequence of a[i:] and b[j:]. + const lcs: number[][] = Array.from({ length: a.length + 1 }, () => + new Array(b.length + 1).fill(0), + ); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lcs[i][j] = + a[i] === b[j] + ? lcs[i + 1][j + 1] + 1 + : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const tokens: Token[] = []; + // Merge runs of the same op so the output is spans, not one node per word. + const push = (op: Op, text: string) => { + const last = tokens[tokens.length - 1]; + if (last && last.op === op) last.text += text; + else tokens.push({ op, text }); + }; + + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + push("same", a[i]); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + push("removed", a[i]); + i++; + } else { + push("added", b[j]); + j++; + } + } + while (i < a.length) push("removed", a[i++]); + while (j < b.length) push("added", b[j++]); + + return tokens; +} + +const OP_STYLE: Record = { + same: {}, + added: { + background: "var(--mantine-color-teal-1)", + color: "var(--mantine-color-teal-9)", + borderRadius: 3, + }, + removed: { + background: "var(--mantine-color-red-1)", + color: "var(--mantine-color-red-9)", + borderRadius: 3, + textDecoration: "line-through", + }, +}; + +/** + * Inline before/after of an edited article body: removed words struck through + * in red, inserted words highlighted in green. Collapsed by default — a + * revision list stays scannable, and the full text is one click away. + */ +export function ArticleBodyDiff({ + fromBody, + toBody, +}: { + fromBody: string; + toBody: string; +}) { + const [open, setOpen] = useState(false); + const tokens = useMemo( + () => (open ? diffWords(fromBody, toBody) : []), + [open, fromBody, toBody], + ); + + return ( + + + + {open && ( + + + {tokens.map((token, index) => ( + + {token.text} + + ))} + + + + + + Removed + + + + + + Added + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index cc2bfb0fb..fcd04fb58 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -262,9 +262,9 @@ export function ContractActionsToolbar({ validityLoading={validityLoading} accepting={mutations.staffAccept.isPending} saving={mutations.updateDocument.isPending} - onAccept={(days, snapshot) => + onAccept={(days, snapshot, window) => mutations.staffAccept.mutate( - { validityDays: days, documentSnapshot: snapshot }, + { validityDays: days, documentSnapshot: snapshot, ...window }, { onSuccess: () => setEditorOpen(false) }, ) } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx index e9e7e4712..de18fa847 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractDocumentEditorModal.tsx @@ -62,6 +62,7 @@ export interface ContractDocumentEditorModalProps { onAccept?: ( validityDays: number, snapshot: Freight.IContractDocumentSnapshot, + window: { validFrom: string; validUntil: string }, ) => void; onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void; } @@ -184,7 +185,10 @@ export function ContractDocumentEditorModal({ (validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000), ); if (days <= 0) return; - onAccept?.(days, snapshot); + onAccept?.(days, snapshot, { + validFrom: validityStart.toISOString(), + validUntil: validityEnd.toISOString(), + }); } else { onSaveEdit?.(snapshot); } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractRevisionTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractRevisionTimeline.tsx index dabf35161..6ae4bec15 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractRevisionTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractRevisionTimeline.tsx @@ -3,6 +3,7 @@ import { History, User } from "lucide-react"; import { Avatar, Badge, + Box, Group, Loader, Stack, @@ -14,6 +15,7 @@ import type { Freight } from "@edr/types"; import { contractsService } from "@/services/contracts.service"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { ArticleBodyDiff } from "@/components/contracts/ArticleBodyDiff"; interface ContractRevisionTimelineProps { contractId: string; @@ -182,6 +184,14 @@ export function ContractRevisionTimeline({ )} {revision.changes.map((change, index) => { const style = CHANGE_STYLES[change.kind]; + // A body edit carries both texts (revisions recorded before + // that change don't) — show the word-level diff inline. + const bodyDiff = + change.kind === "ARTICLE_BODY_CHANGED" && + change.fromBody != null && + change.toBody != null + ? { from: change.fromBody, to: change.toBody } + : null; return ( {style?.label ?? change.kind} - - {changeSubject(change)} - + + + {changeSubject(change)} + + {bodyDiff && ( + + )} + ); })} diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index afbb6f627..cb125ddff 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -138,11 +138,14 @@ export function useContractMutations(contractId: string) { mutationFn: (payload: { validityDays: number; documentSnapshot?: Freight.IContractDocumentSnapshot; + validFrom?: string; + validUntil?: string; }) => contractsService.staffAccept( contractId, payload.validityDays, payload.documentSnapshot, + { validFrom: payload.validFrom, validUntil: payload.validUntil }, ), onSuccess: (data) => onSuccess(data, "Contract accepted for approval"), onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")), diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index be361da75..2f4c3d50c 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -203,10 +203,12 @@ export const contractsService = { id: string, validityDays: number, documentSnapshot?: Freight.IContractDocumentSnapshot, + window?: { validFrom?: string; validUntil?: string }, ) => postContract(C.STAFF_ACCEPT(id), { validityDays, documentSnapshot, + ...window, }), /** The editable per-contract document draft (snapshot or live template). */