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

557 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Box,
Button,
Divider,
Group,
Loader,
Modal,
ScrollArea,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
Textarea,
TextInput,
// Tooltip, // ponytail: back with the article editor block
} from "@mantine/core";
import {
// ArrowDown, // ponytail: back with the article editor block
// ArrowUp,
FileText,
Info,
Lock,
Plus,
Trash2,
} from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
/** New client-side article id (server keeps whatever id we send). */
function newArticleId(): string {
const c = globalThis.crypto;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/** Midnight today — the earliest day a contract's validity may start. */
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
/** Local `YYYY-MM-DD` — the shape Mantine hands day cells. */
function localDay(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/**
* Print today in bold inside the calendar. `highlightToday` only rings the cell,
* which staff read as "disabled" on a picker whose minimum IS today — the weight
* makes it obvious the day is pickable.
*/
function boldToday(date: string) {
return date === localDay(new Date())
? { style: { fontWeight: 800 } }
: {};
}
interface EditableArticle {
id: string;
title: string;
body: string;
}
export interface ContractDocumentEditorModalProps {
opened: boolean;
onClose: () => void;
contractId: string;
/**
* "accept" — shown from the Accept-for-approval action: pick a validity window
* and (optionally) edit the articles, then start the approval chain.
* "edit" — re-edit the frozen articles of an already-accepted contract before
* generating/regenerating its PDF.
*/
mode: "accept" | "edit";
/** Validity options (accept mode only). */
validityOptions?: Array<{ value: string; label: string }>;
validityLoading?: boolean;
accepting?: boolean;
saving?: boolean;
onAccept?: (
validityDays: number,
snapshot: Freight.IContractDocumentSnapshot,
window: { validFrom: string; validUntil: string },
) => void;
onSaveEdit?: (snapshot: Freight.IContractDocumentSnapshot) => void;
}
/**
* Per-contract contract-document editor. Loads the resolved template (or this
* contract's frozen snapshot) and lets staff add/remove/reorder/edit articles
* for THIS contract only — it never writes back to the shared six templates.
*/
export function ContractDocumentEditorModal({
opened,
onClose,
contractId,
mode,
// ponytail: validityOptions/validityLoading fed the now-commented dropdown
// above — caller still passes them, left unread here for a quick revert.
// validityOptions = [],
// validityLoading = false,
accepting = false,
saving = false,
onAccept,
onSaveEdit,
}: ContractDocumentEditorModalProps) {
const { data: draft, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-draft"],
queryFn: () => contractsService.getContractDocumentDraft(contractId),
enabled: opened && Boolean(contractId),
// Always refetch the current draft when the dialog opens.
staleTime: 0,
});
const [documentTitle, setDocumentTitle] = useState("");
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
const [articles, setArticles] = useState<EditableArticle[]>([]);
// const [validityDays, setValidityDays] = useState<string | null>(null);
// ponytail: client keeps flip-flopping the validity requirement — swapped
// the validity dropdown for explicit start/end dates, kept above commented
// instead of deleted so it's a one-line revert if they flip back.
const [validityStart, setValidityStart] = useState<Date | null>(null);
const [validityEnd, setValidityEnd] = useState<Date | null>(null);
// Seed the editor from the loaded draft whenever the dialog (re)opens.
useEffect(() => {
if (!opened || !draft) return;
setDocumentTitle(draft.documentTitle ?? "");
setWhereasClauses(draft.whereasClauses ?? []);
setArticles(
(draft.articles ?? []).map((a) => ({
id: a.id || newArticleId(),
title: a.title,
body: a.body,
})),
);
}, [opened, draft]);
// Accept mode opens on NOW — a contract never starts in the past, and the
// pickers below refuse earlier days. Seconds are dropped so the value matches
// what the HH:mm picker shows.
useEffect(() => {
if (!opened || mode !== "accept") return;
const now = new Date();
now.setSeconds(0, 0);
setValidityStart(now);
setValidityEnd(null);
}, [opened, mode]);
// Default validity to the first configured option (accept mode).
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
// setValidityDays(validityOptions[0].value);
// }
// }, [mode, validityDays, validityOptions]);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
const locked = mode === "edit" && !draft?.editableByMe;
// Article edit handlers — parked with the editor block below.
// const moveArticle = (index: number, delta: number) => {
// setArticles((prev) => {
// const next = [...prev];
// const target = index + delta;
// if (target < 0 || target >= next.length) return prev;
// [next[index], next[target]] = [next[target], next[index]];
// return next;
// });
// };
// const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
// setArticles((prev) =>
// prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
// );
// const removeArticle = (id: string) =>
// setArticles((prev) => prev.filter((a) => a.id !== id));
// const addArticle = () =>
// setArticles((prev) => [
// ...prev,
// { id: newArticleId(), title: "", body: "" },
// ]);
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
code: draft?.code ?? null,
name: draft?.name ?? null,
documentTitle: documentTitle.trim() || null,
whereasClauses: whereasClauses
.map((c) => c.trim())
.filter((c) => c.length > 0),
articles: articles
.filter((a) => a.title.trim().length > 0 || a.body.trim().length > 0)
.map((a, index) => ({
id: a.id,
title: a.title.trim(),
body: a.body,
order: index + 1,
})),
});
const hasArticles = useMemo(
() => articles.some((a) => a.title.trim() || a.body.trim()),
[articles],
);
const submit = () => {
const snapshot = buildSnapshot();
if (mode === "accept") {
// const days = Number(validityDays);
// if (!days) return;
if (!validityStart || !validityEnd) return;
const days = Math.ceil(
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
);
if (days <= 0) return;
onAccept?.(days, snapshot, {
validFrom: validityStart.toISOString(),
validUntil: validityEnd.toISOString(),
});
} else {
onSaveEdit?.(snapshot);
}
};
const submitting = accepting || saving;
const canSubmit =
hasArticles &&
!locked &&
// (mode === "edit" || Boolean(validityDays)) &&
(mode === "edit" || Boolean(validityStart && validityEnd)) &&
!submitting;
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
centered
radius="md"
title={
<Group gap="xs">
<FileText size={18} />
<Text fw={700}>
{mode === "accept"
? "Review contract document & accept"
: "Edit contract document"}
</Text>
</Group>
}
>
{isLoading ? (
<Group justify="center" py="xl">
<Loader size="sm" color="gray" />
<Text size="sm" c="dimmed">
Loading document
</Text>
</Group>
) : (
<Stack gap="md">
<Alert
variant="light"
color={locked ? "orange" : "blue"}
icon={locked ? <Lock size={16} /> : <Info size={16} />}
>
{locked
? draft?.nextApproverRole
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
: "This document can no longer be edited — the contract has advanced beyond approval."
: "This document is read-only — it is accepted exactly as the template produced it. Set the validity dates, then accept."}
</Alert>
{/* Accept is a REVIEW step: the document is shown exactly as the
template produced it, with nothing editable. Any wording change
belongs to the template or to the separate edit action. */}
{mode === "accept" ? (
<ScrollArea.Autosize mah={340} type="auto">
<Stack gap="sm" pr="sm">
<Text fw={700} fz={15}>
{documentTitle || "Contract document"}
</Text>
{whereasClauses.length > 0 && (
<Stack gap={4}>
{whereasClauses.map((clause, i) => (
<Text key={i} fz={13} c="dimmed">
WHEREAS {clause}
</Text>
))}
</Stack>
)}
{articles.length === 0 ? (
<Text fz={13} c="dimmed">
This template carries no articles.
</Text>
) : (
articles.map((article, index) => (
<Box key={article.id}>
<Text fz={13} fw={700}>
Article {index + 1}
{article.title ? `${article.title}` : ""}
</Text>
<Text
fz={12.5}
c="dimmed"
style={{ whiteSpace: "pre-wrap" }}
>
{article.body}
</Text>
</Box>
))
)}
</Stack>
</ScrollArea.Autosize>
) : (
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
)}
{mode !== "accept" && (
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600}>
WHEREAS recitals
</Text>
<Button
size="compact-xs"
variant="light"
color="gray"
leftSection={<Plus size={13} />}
disabled={locked}
onClick={() => setWhereasClauses((p) => [...p, ""])}
>
Add recital
</Button>
</Group>
{whereasClauses.length === 0 ? (
<Text size="xs" c="dimmed">
No recitals.
</Text>
) : (
<Stack gap="xs">
{whereasClauses.map((clause, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-start">
<Textarea
style={{ flex: 1 }}
autosize
minRows={1}
value={clause}
disabled={locked}
onChange={(e) =>
setWhereasClauses((prev) =>
prev.map((c, idx) =>
idx === i ? e.currentTarget.value : c,
),
)
}
/>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() =>
setWhereasClauses((prev) =>
prev.filter((_, idx) => idx !== i),
)
}
>
<Trash2 size={15} />
</ActionIcon>
</Group>
))}
</Stack>
)}
</Box>
)}
{/* Article editing is hidden for now (frontend only) — staff accept the
contract on the template's articles as-is. The articles themselves
still ride along in buildSnapshot(), so the generated document is
unchanged. Uncomment this block to bring the editor back.
<Divider label="Articles" labelPosition="left" />
<Stack gap="md">
{articles.map((article, index) => (
<Box
key={article.id}
p="sm"
style={{
border: "1px solid var(--mantine-color-gray-3)",
borderRadius: 8,
}}
>
<Group justify="space-between" mb="xs" wrap="nowrap">
<Text size="sm" fw={700} c="dimmed">
Article {index + 1}
</Text>
<Group gap={4} wrap="nowrap">
<Tooltip label="Move up" withArrow>
<ActionIcon
variant="subtle"
color="gray"
disabled={locked || index === 0}
onClick={() => moveArticle(index, -1)}
>
<ArrowUp size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down" withArrow>
<ActionIcon
variant="subtle"
color="gray"
disabled={locked || index === articles.length - 1}
onClick={() => moveArticle(index, 1)}
>
<ArrowDown size={15} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete article" withArrow>
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => removeArticle(article.id)}
>
<Trash2 size={15} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
<Stack gap="xs">
<TextInput
placeholder="Article title"
value={article.title}
disabled={locked}
onChange={(e) =>
updateArticle(article.id, { title: e.currentTarget.value })
}
/>
<Textarea
placeholder="Article body — each line becomes a numbered clause."
autosize
minRows={3}
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
value={article.body}
disabled={locked}
onChange={(e) =>
updateArticle(article.id, { body: e.currentTarget.value })
}
/>
</Stack>
</Box>
))}
<Button
variant="light"
color="edr-green"
leftSection={<Plus size={15} />}
disabled={locked}
onClick={addArticle}
>
Add article
</Button>
</Stack>
*/}
<Divider />
{mode === "accept" && (
<>
{/* ponytail: client keeps changing this requirement — swapped
the validity-period dropdown for explicit start/end dates,
left the old block commented instead of deleted. */}
{/* {validityOptions.length > 0 ? (
<Select
label="Contract validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under Dropdown Settings."}
</Text>
)} */}
<Group grow align="flex-start">
<DateTimePicker
label="Start date & time"
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
// Today is the earliest start — and it is ringed in the
// calendar so it reads as selectable rather than blocked.
minDate={startOfToday()}
maxDate={validityEnd ?? undefined}
highlightToday
getDayProps={boldToday}
valueFormat="DD MMM YYYY HH:mm"
clearable
/>
<DateTimePicker
label="End date & time"
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? startOfToday()}
highlightToday
getDayProps={boldToday}
valueFormat="DD MMM YYYY HH:mm"
clearable
/>
</Group>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
color="edr-green"
loading={submitting}
disabled={!canSubmit}
onClick={submit}
>
{mode === "accept"
? "Accept & start approval"
: "Save document changes"}
</Button>
</Group>
</Stack>
)}
</Modal>
);
}