add contract hazard declaration, DO collection dates, and booking request currency migrations; implement article body diff component and integrate into contract revision timeline

This commit is contained in:
Marshal
2026-07-26 19:09:01 +00:00
parent af85f20789
commit 5fa012cad3
10 changed files with 262 additions and 6 deletions

View File

@@ -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<typeof diffWords>,
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);
});
});

View File

@@ -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<number>(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<Op, React.CSSProperties> = {
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 (
<Box mt={4}>
<Button
variant="subtle"
size="compact-xs"
color="gray"
px={4}
leftSection={
open ? <ChevronDown size={12} /> : <ChevronRight size={12} />
}
onClick={() => setOpen((v) => !v)}
>
{open ? "Hide changes" : "View changes"}
</Button>
{open && (
<Box
mt={6}
p="sm"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
maxHeight: 320,
overflowY: "auto",
}}
>
<Text
size="xs"
component="div"
style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}
>
{tokens.map((token, index) => (
<span key={index} style={OP_STYLE[token.op]}>
{token.text}
</span>
))}
</Text>
<Group gap="md" mt="xs">
<Group gap={4}>
<Box w={10} h={10} style={{ ...OP_STYLE.removed, borderRadius: 2 }} />
<Text size="10px" c="dimmed">
Removed
</Text>
</Group>
<Group gap={4}>
<Box w={10} h={10} style={{ ...OP_STYLE.added, borderRadius: 2 }} />
<Text size="10px" c="dimmed">
Added
</Text>
</Group>
</Group>
</Box>
)}
</Box>
);
}

View File

@@ -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) },
)
}

View File

@@ -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);
}

View File

@@ -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 (
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
<Badge
@@ -192,9 +202,17 @@ export function ContractRevisionTimeline({
>
{style?.label ?? change.kind}
</Badge>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
{bodyDiff && (
<ArticleBodyDiff
fromBody={bodyDiff.from}
toBody={bodyDiff.to}
/>
)}
</Box>
</Group>
);
})}

View File

@@ -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")),

View File

@@ -203,10 +203,12 @@ export const contractsService = {
id: string,
validityDays: number,
documentSnapshot?: Freight.IContractDocumentSnapshot,
window?: { validFrom?: string; validUntil?: string },
) =>
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), {
validityDays,
documentSnapshot,
...window,
}),
/** The editable per-contract document draft (snapshot or live template). */