mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
418 lines
13 KiB
TypeScript
418 lines
13 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
ActionIcon,
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Modal,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "@mantine/core";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
FileText,
|
|
Info,
|
|
Lock,
|
|
Plus,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
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)}`;
|
|
}
|
|
|
|
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,
|
|
) => 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,
|
|
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);
|
|
|
|
// 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]);
|
|
|
|
// 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;
|
|
|
|
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;
|
|
onAccept?.(days, snapshot);
|
|
} else {
|
|
onSaveEdit?.(snapshot);
|
|
}
|
|
};
|
|
|
|
const submitting = accepting || saving;
|
|
const canSubmit =
|
|
hasArticles &&
|
|
!locked &&
|
|
(mode === "edit" || Boolean(validityDays)) &&
|
|
!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."
|
|
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
|
</Alert>
|
|
|
|
<TextInput
|
|
label="Document title"
|
|
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
|
|
value={documentTitle}
|
|
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
|
|
disabled={locked}
|
|
/>
|
|
|
|
<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>
|
|
|
|
<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. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
|
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" && (
|
|
<>
|
|
{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 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>
|
|
);
|
|
}
|