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

@@ -13,6 +13,7 @@ import {
PackageOpen,
Paperclip,
Receipt,
ScrollText,
Send,
Settings,
ShieldCheck,
@@ -84,6 +85,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement
import UsersPage from "./pages/dashboard/user-management/UsersPage";
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
@@ -461,6 +464,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <Settings />,
permission: FREIGHT_PERMS.admin,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
},
],
},
{
@@ -1252,6 +1261,22 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="contract-templates"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractTemplatesPage />
</RequirePermission>
}
/>
<Route
path="contract-templates/:code"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<ContractTemplateEditorPage />
</RequirePermission>
}
/>
<Route
path="configuration"

View File

@@ -0,0 +1,98 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
contractTemplatesService,
type ArticlePayload,
type UpdateContractTemplatePayload,
} from "@/services/contract-templates.service";
const KEYS = {
ROOT: ["contract-templates"] as const,
list: () => ["contract-templates", "list"] as const,
byCode: (code: string) => ["contract-templates", "detail", code] as const,
preview: (code: string) => ["contract-templates", "preview", code] as const,
};
export function useContractTemplates() {
return useQuery({
queryKey: KEYS.list(),
queryFn: () => contractTemplatesService.list(),
});
}
export function useContractTemplate(code: string | undefined) {
return useQuery({
queryKey: KEYS.byCode(code ?? ""),
queryFn: () => contractTemplatesService.getByCode(code as string),
enabled: Boolean(code),
});
}
/** Rendered mock-data HTML preview of the template's saved state. */
export function useContractTemplatePreview(code: string | undefined, enabled = true) {
return useQuery({
queryKey: KEYS.preview(code ?? ""),
queryFn: () => contractTemplatesService.preview(code as string),
enabled: Boolean(code) && enabled,
staleTime: 0,
});
}
function useTemplateMutation<TVariables>(
mutationFn: (vars: TVariables) => Promise<unknown>,
successMessage: string,
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: () => {
toast.success(successMessage);
void queryClient.invalidateQueries({ queryKey: KEYS.ROOT });
},
onError: (error: unknown) => {
const message =
(error as { response?: { data?: { message?: string } } })?.response?.data
?.message ?? "Something went wrong";
toast.error(Array.isArray(message) ? message.join(", ") : message);
},
});
}
export function useUpdateContractTemplate(code: string) {
return useTemplateMutation(
(payload: UpdateContractTemplatePayload) =>
contractTemplatesService.update(code, payload),
"Template updated",
);
}
export function useAddArticle(code: string) {
return useTemplateMutation(
(payload: ArticlePayload) => contractTemplatesService.addArticle(code, payload),
"Article added",
);
}
export function useUpdateArticle(code: string) {
return useTemplateMutation(
(vars: { articleId: string; payload: Partial<ArticlePayload> }) =>
contractTemplatesService.updateArticle(code, vars.articleId, vars.payload),
"Article updated",
);
}
export function useRemoveArticle(code: string) {
return useTemplateMutation(
(articleId: string) => contractTemplatesService.removeArticle(code, articleId),
"Article removed",
);
}
export function useReplaceArticles(code: string) {
return useTemplateMutation(
(articles: Array<{ id?: string; title: string; body: string }>) =>
contractTemplatesService.replaceArticles(code, articles),
"Articles reordered",
);
}

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

View File

@@ -0,0 +1,112 @@
import { api as client } from "../auth/http";
const BASE = "/contract-templates";
export interface ContractTemplateArticle {
id: string;
title: string;
body: string;
order: number;
}
export interface ContractTemplate {
id: string;
code:
| "IMPORT_BULK"
| "EXPORT_BULK"
| "INTERCITY_BULK"
| "IMPORT_CONTAINER"
| "EXPORT_CONTAINER"
| "INTERCITY_CONTAINER";
name: string;
description?: string | null;
documentTitle: string;
whereasClauses: string[];
articles: ContractTemplateArticle[];
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface UpdateContractTemplatePayload {
name?: string;
description?: string;
documentTitle?: string;
whereasClauses?: string[];
isActive?: boolean;
}
export interface ArticlePayload {
title: string;
body: string;
position?: number;
}
export const contractTemplatesService = {
async list(): Promise<ContractTemplate[]> {
const { data } = await client.get<ContractTemplate[]>(BASE);
return data;
},
async getByCode(code: string): Promise<ContractTemplate> {
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
return data;
},
async update(
code: string,
payload: UpdateContractTemplatePayload,
): Promise<ContractTemplate> {
const { data } = await client.patch<ContractTemplate>(
`${BASE}/${code}`,
payload,
);
return data;
},
async preview(code: string): Promise<{ html: string }> {
const { data } = await client.post<{ html: string }>(
`${BASE}/${code}/preview`,
{},
);
return data;
},
async addArticle(code: string, payload: ArticlePayload): Promise<ContractTemplate> {
const { data } = await client.post<ContractTemplate>(
`${BASE}/${code}/articles`,
payload,
);
return data;
},
async updateArticle(
code: string,
articleId: string,
payload: Partial<ArticlePayload>,
): Promise<ContractTemplate> {
const { data } = await client.patch<ContractTemplate>(
`${BASE}/${code}/articles/${articleId}`,
payload,
);
return data;
},
async removeArticle(code: string, articleId: string): Promise<ContractTemplate> {
const { data } = await client.delete<ContractTemplate>(
`${BASE}/${code}/articles/${articleId}`,
);
return data;
},
async replaceArticles(
code: string,
articles: Array<{ id?: string; title: string; body: string }>,
): Promise<ContractTemplate> {
const { data } = await client.put<ContractTemplate>(
`${BASE}/${code}/articles`,
{ articles },
);
return data;
},
};