make a contrat template

This commit is contained in:
Marshal
2026-07-09 21:13:07 +00:00
parent e4429592d3
commit 3443b79644
26 changed files with 3243 additions and 41 deletions

View File

@@ -0,0 +1,468 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Group,
Loader,
Modal,
Paper,
Stack,
Switch,
Text,
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import {
ArrowDown,
ArrowUp,
Pencil,
Plus,
RefreshCw,
Settings2,
Trash2,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import {
useAddArticle,
useContractTemplate,
useContractTemplatePreview,
useRemoveArticle,
useReplaceArticles,
useUpdateArticle,
useUpdateContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
const BODY_HINT =
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
interface ArticleDraft {
id?: string;
title: string;
body: string;
}
export default function ContractTemplateEditorPage() {
const { code } = useParams<{ code: string }>();
const { data: template, isLoading } = useContractTemplate(code);
const preview = useContractTemplatePreview(code);
const updateTemplate = useUpdateContractTemplate(code ?? "");
const addArticle = useAddArticle(code ?? "");
const updateArticle = useUpdateArticle(code ?? "");
const removeArticle = useRemoveArticle(code ?? "");
const replaceArticles = useReplaceArticles(code ?? "");
const [articleDraft, setArticleDraft] = useState<ArticleDraft | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ContractTemplateArticle | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const sortedArticles = useMemo(
() => [...(template?.articles ?? [])].sort((a, b) => a.order - b.order),
[template],
);
const moveArticle = (index: number, delta: -1 | 1) => {
const next = [...sortedArticles];
const target = index + delta;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
replaceArticles.mutate(
next.map(({ id, title, body }) => ({ id, title, body })),
);
};
const saveArticle = () => {
if (!articleDraft) return;
if (articleDraft.id) {
updateArticle.mutate({
articleId: articleDraft.id,
payload: { title: articleDraft.title, body: articleDraft.body },
});
} else {
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
}
setArticleDraft(null);
};
if (isLoading || !template) {
return (
<PageContainer>
<Center h={360}>
<Loader color="edr-green" />
</Center>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title={template.name}
subtitle={template.documentTitle}
backTo="/dashboard/contract-templates"
meta={
<Group gap={6}>
<Badge variant="outline" color="edr-green">
{template.code.replaceAll("_", " · ")}
</Badge>
{!template.isActive && (
<Badge variant="light" color="red">
Inactive
</Badge>
)}
</Group>
}
action={
<Group gap="sm">
<Switch
color="edr-green"
label="Active"
checked={template.isActive}
onChange={(event) =>
updateTemplate.mutate({ isActive: event.currentTarget.checked })
}
/>
<Button
variant="light"
color="edr-green"
leftSection={<Settings2 size={16} />}
onClick={() => setDetailsOpen(true)}
>
Document details
</Button>
<Button
color="edr-green"
leftSection={<Plus size={16} />}
onClick={() => setArticleDraft({ title: "", body: "" })}
>
Add article
</Button>
</Group>
}
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* ── Article list ─────────────────────────────────────────────── */}
<Stack gap="sm">
{sortedArticles.map((article, index) => (
<Card key={article.id} withBorder radius="lg" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-green.7">
Article {index + 1}
</Text>
<Title order={5}>{article.title}</Title>
<Text size="sm" c="dimmed" lineClamp={2} mt={4}>
{article.body}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === 0}
onClick={() => moveArticle(index, -1)}
>
<ArrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === sortedArticles.length - 1}
onClick={() => moveArticle(index, 1)}
>
<ArrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit article">
<ActionIcon
variant="subtle"
color="edr-green"
onClick={() =>
setArticleDraft({
id: article.id,
title: article.title,
body: article.body,
})
}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove article">
<ActionIcon
variant="subtle"
color="red"
onClick={() => setDeleteTarget(article)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Card>
))}
{sortedArticles.length === 0 && (
<Card withBorder radius="lg" padding="xl">
<Center>
<Text c="dimmed">
No articles yet add the first article to build this contract.
</Text>
</Center>
</Card>
)}
</Stack>
{/* ── Live preview ─────────────────────────────────────────────── */}
<Card withBorder radius="lg" padding="sm" className="xl:sticky xl:top-4 self-start">
<Group justify="space-between" mb="xs" px={4}>
<Text fw={600} size="sm">
Document preview (mock data)
</Text>
<Tooltip label="Refresh preview">
<ActionIcon
variant="subtle"
color="edr-green"
loading={preview.isFetching}
onClick={() => void preview.refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
{preview.isLoading ? (
<Center h={480}>
<Loader color="edr-green" />
</Center>
) : (
<iframe
title="Template preview"
srcDoc={preview.data?.html}
style={{
width: "100%",
height: "calc(100vh - 240px)",
minHeight: 480,
border: 0,
background: "#f3f8f5",
}}
/>
)}
</Paper>
</Card>
</div>
{/* ── Add / edit article modal ───────────────────────────────────── */}
<Modal
opened={Boolean(articleDraft)}
onClose={() => setArticleDraft(null)}
title={articleDraft?.id ? "Edit article" : "Add article"}
size="xl"
>
{articleDraft && (
<Stack gap="sm">
<TextInput
label="Article title"
placeholder="e.g. Obligations of the Client"
value={articleDraft.title}
onChange={(event) =>
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
}
required
/>
<Textarea
label="Article body"
description={BODY_HINT}
value={articleDraft.body}
onChange={(event) =>
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
}
autosize
minRows={12}
maxRows={24}
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setArticleDraft(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={
articleDraft.title.trim().length < 2 ||
articleDraft.body.trim().length < 2
}
loading={addArticle.isPending || updateArticle.isPending}
onClick={saveArticle}
>
{articleDraft.id ? "Save changes" : "Add article"}
</Button>
</Group>
</Stack>
)}
</Modal>
{/* ── Delete confirm ─────────────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Remove article"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove <strong>{deleteTarget?.title}</strong> from this template? The
remaining articles are renumbered automatically.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeArticle.isPending}
onClick={() => {
if (deleteTarget) removeArticle.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
>
Remove article
</Button>
</Group>
</Stack>
</Modal>
{/* ── Document details modal ─────────────────────────────────────── */}
<DocumentDetailsModal
opened={detailsOpen}
onClose={() => setDetailsOpen(false)}
initial={{
name: template.name,
description: template.description ?? "",
documentTitle: template.documentTitle,
whereasClauses: template.whereasClauses,
}}
saving={updateTemplate.isPending}
onSave={(values) => {
updateTemplate.mutate(values);
setDetailsOpen(false);
}}
/>
</PageContainer>
);
}
interface DocumentDetailsModalProps {
opened: boolean;
onClose: () => void;
initial: {
name: string;
description: string;
documentTitle: string;
whereasClauses: string[];
};
saving: boolean;
onSave: (values: {
name: string;
description: string;
documentTitle: string;
whereasClauses: string[];
}) => void;
}
function DocumentDetailsModal({
opened,
onClose,
initial,
saving,
onSave,
}: DocumentDetailsModalProps) {
const [name, setName] = useState(initial.name);
const [description, setDescription] = useState(initial.description);
const [documentTitle, setDocumentTitle] = useState(initial.documentTitle);
const [whereas, setWhereas] = useState(initial.whereasClauses.join("\n\n"));
// Re-sync local state each time the modal opens with fresh server data.
const [lastOpened, setLastOpened] = useState(false);
if (opened && !lastOpened) {
setName(initial.name);
setDescription(initial.description);
setDocumentTitle(initial.documentTitle);
setWhereas(initial.whereasClauses.join("\n\n"));
setLastOpened(true);
} else if (!opened && lastOpened) {
setLastOpened(false);
}
return (
<Modal opened={opened} onClose={onClose} title="Document details" size="xl">
<Stack gap="sm">
<TextInput
label="Template name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<TextInput
label="Cover page title"
description="Printed on the contract cover, e.g. “Import Container Transport Service by Railway”."
value={documentTitle}
onChange={(event) => setDocumentTitle(event.currentTarget.value)}
required
/>
<Textarea
label="Card description"
description="Shown on the Templates page card only — not printed."
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
autosize
minRows={2}
/>
<Textarea
label="Recitals (WHEREAS clauses)"
description="One recital per paragraph — separate recitals with a blank line."
value={whereas}
onChange={(event) => setWhereas(event.currentTarget.value)}
autosize
minRows={5}
maxRows={12}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
loading={saving}
disabled={name.trim().length < 3 || documentTitle.trim().length < 3}
onClick={() =>
onSave({
name: name.trim(),
description: description.trim(),
documentTitle: documentTitle.trim(),
whereasClauses: whereas
.split(/\n\s*\n/)
.map((clause) => clause.trim())
.filter(Boolean),
})
}
>
Save details
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,152 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Button,
Card,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplate } from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
const DIRECTION_LABEL: Record<string, string> = {
IMPORT: "Import",
EXPORT: "Export",
INTERCITY: "Intercity",
};
const DIRECTION_COLOR: Record<string, string> = {
IMPORT: "edr-green",
EXPORT: "teal",
INTERCITY: "lime",
};
function templateDirection(code: ContractTemplate["code"]): string {
return code.split("_")[0];
}
function isBulk(code: ContractTemplate["code"]): boolean {
return code.endsWith("_BULK");
}
export default function ContractTemplatesPage() {
const navigate = useNavigate();
const { data: templates, isLoading } = useContractTemplates();
const [previewCode, setPreviewCode] = useState<string | null>(null);
const previewTemplate = templates?.find((t) => t.code === previewCode);
return (
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
/>
{isLoading ? (
<Center h={320}>
<Loader color="edr-green" />
</Center>
) : (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{(templates ?? []).map((template) => {
const direction = templateDirection(template.code);
return (
<Card key={template.code} withBorder radius="xl" padding="lg">
<Stack gap="sm" h="100%">
<Group justify="space-between" align="flex-start">
<ThemeIcon
size={44}
radius="md"
variant="light"
color="edr-green"
>
{isBulk(template.code) ? (
<Boxes size={24} />
) : (
<Container size={24} />
)}
</ThemeIcon>
<Group gap={6}>
<Badge
variant="light"
color={DIRECTION_COLOR[direction] ?? "edr-green"}
>
{DIRECTION_LABEL[direction] ?? direction}
</Badge>
<Badge variant="outline" color="gray">
{isBulk(template.code) ? "Bulk" : "Container"}
</Badge>
{!template.isActive && (
<Badge variant="light" color="red">
Inactive
</Badge>
)}
</Group>
</Group>
<div>
<Text fw={700} size="lg">
{template.name}
</Text>
<Text size="sm" c="dimmed" lineClamp={3}>
{template.description || template.documentTitle}
</Text>
</div>
<Group gap="xs" mt="auto">
<FileSignature size={14} className="text-edr-primary" />
<Text size="xs" c="dimmed">
{template.articles.length} articles · updated{" "}
{new Date(template.updatedAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</Text>
</Group>
<Group grow>
<Button
variant="light"
color="edr-green"
leftSection={<Eye size={16} />}
onClick={() => setPreviewCode(template.code)}
>
Preview
</Button>
<Button
color="edr-green"
leftSection={<Pencil size={16} />}
onClick={() =>
navigate(`/dashboard/contract-templates/${template.code}`)
}
>
Edit articles
</Button>
</Group>
</Stack>
</Card>
);
})}
</SimpleGrid>
)}
<TemplatePreviewModal
code={previewCode}
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
onClose={() => setPreviewCode(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,49 @@
import { Center, Loader, Modal, Paper, Text } from "@mantine/core";
import { useContractTemplatePreview } from "@/hooks/contract-templates/useContractTemplates";
interface TemplatePreviewModalProps {
code: string | null;
title?: string;
onClose: () => void;
}
/** Full-document HTML preview rendered by the API against mock contract data. */
export default function TemplatePreviewModal({
code,
title,
onClose,
}: TemplatePreviewModalProps) {
const { data, isLoading, isError } = useContractTemplatePreview(
code ?? undefined,
Boolean(code),
);
return (
<Modal
opened={Boolean(code)}
onClose={onClose}
title={title ?? "Contract preview"}
size="90%"
padding="sm"
>
{isLoading ? (
<Center h={420}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Center h={200}>
<Text c="red">Failed to render the preview.</Text>
</Center>
) : (
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
<iframe
title="Contract template preview"
srcDoc={data?.html}
style={{ width: "100%", height: "72vh", border: 0, background: "#f3f8f5" }}
/>
</Paper>
)}
</Modal>
);
}