mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
make a contrat template
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user