feat(contracts): per-cargo bulk contract templates

This commit is contained in:
Marshal
2026-08-07 23:50:27 +00:00
parent d2eb47d14b
commit cd90ccb0f5
18 changed files with 802 additions and 107 deletions

View File

@@ -590,7 +590,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
permission: FREIGHT_PERMS.admin,
// `view` opens the page; `read` alone is API-only and shows no menu.
permission: [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
],
},
{
label: "Audit logs",
@@ -1470,7 +1474,12 @@ const App = () => {
<Route
path="contract-templates"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplatesPage />
</RequirePermission>
}
@@ -1478,7 +1487,12 @@ const App = () => {
<Route
path="contract-templates/:code"
element={
<RequirePermission permission={FREIGHT_PERMS.admin}>
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
>
<ContractTemplateEditorPage />
</RequirePermission>
}

View File

@@ -4,6 +4,7 @@ import toast from "react-hot-toast";
import {
contractTemplatesService,
type ArticlePayload,
type CreateContractTemplatePayload,
type UpdateContractTemplatePayload,
} from "@/services/contract-templates.service";
@@ -59,6 +60,21 @@ function useTemplateMutation<TVariables>(
});
}
export function useCreateContractTemplate() {
return useTemplateMutation(
(payload: CreateContractTemplatePayload) =>
contractTemplatesService.create(payload),
"Template created",
);
}
export function useDeleteContractTemplate() {
return useTemplateMutation(
(code: string) => contractTemplatesService.remove(code),
"Template deleted",
);
}
export function useUpdateContractTemplate(code: string) {
return useTemplateMutation(
(payload: UpdateContractTemplatePayload) =>

View File

@@ -317,6 +317,10 @@ export const FREIGHT_PERMS = {
contractTemplates: {
view: "edr_freight_app:settings:contract_templates:view",
manage: "edr_freight_app:settings:contract_templates:manage",
create: "edr_freight_app:settings:contract_templates:create",
update: "edr_freight_app:settings:contract_templates:update",
delete: "edr_freight_app:settings:contract_templates:delete",
read: "edr_freight_app:settings:contract_templates:read",
},
},
audit: {

View File

@@ -1,11 +1,15 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
SegmentedControl,
Select,
SimpleGrid,
Skeleton,
Stack,
@@ -19,11 +23,21 @@ import {
Container,
Eye,
FileText,
Lock,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
import {
useContractTemplates,
useCreateContractTemplate,
useDeleteContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { cargoTypesService } from "@/services/cargo-types.service";
import type { ContractTemplate } from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
@@ -39,22 +53,18 @@ const DIRECTION_DOT: Record<string, string> = {
INTERCITY: "var(--mantine-color-orange-5)",
};
function templateDirection(code: ContractTemplate["code"]): string {
return code.split("_")[0];
function isBulk(template: ContractTemplate): boolean {
return Boolean(template.cargoTypeId);
}
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
// the second one — never the suffix.
function isBulk(code: ContractTemplate["code"]): boolean {
return code.split("_")[1] === "BULK";
}
// Intercity is domestic and crosses no border, so it has no customs variant at
// all — hence null rather than false, which would wrongly read as a deliberate
// "client clears its own customs" choice.
function customsVariant(code: ContractTemplate["code"]): boolean | null {
if (code.endsWith("_NO_CUSTOMS")) return false;
if (code.endsWith("_CUSTOMS")) return true;
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
// domestic and crosses no border, so it has no customs variant at all — hence
// null rather than false, which would wrongly read as a deliberate "client
// clears its own customs" choice.
function customsVariant(template: ContractTemplate): boolean | null {
if (isBulk(template)) return template.withCustoms ?? null;
if (template.code.endsWith("_NO_CUSTOMS")) return false;
if (template.code.endsWith("_CUSTOMS")) return true;
return null;
}
@@ -66,10 +76,28 @@ function formatUpdated(value: string): string {
});
}
interface CargoTypeOption {
id: string;
cargoTypeName?: string;
hasContractTemplate?: boolean;
}
export default function ContractTemplatesPage() {
const navigate = useNavigate();
const { user } = useAuth();
const { data: templates, isLoading } = useContractTemplates();
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ContractTemplate | null>(null);
const perms = FREIGHT_PERMS.settings.contractTemplates;
const isAdmin = hasPermission(user, FREIGHT_PERMS.admin);
const canManage = isAdmin || hasPermission(user, perms.manage);
const canCreate = canManage || hasPermission(user, perms.create);
const canUpdate = canManage || hasPermission(user, perms.update);
const canDelete = isAdmin || hasPermission(user, perms.delete);
const deleteTemplate = useDeleteContractTemplate();
const previewTemplate = templates?.find((t) => t.code === previewCode);
@@ -77,20 +105,34 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per commodity and customs option. Articles are fully editable."
action={
canCreate ? (
<Button
color="edr-green"
leftSection={<Plus size={16} />}
onClick={() => setCreateOpen(true)}
>
New bulk template
</Button>
) : undefined
}
/>
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{isLoading
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
: (templates ?? []).map((template) => (
<TemplateCard
key={template.code}
template={template}
canUpdate={canUpdate}
canDelete={canDelete}
onPreview={() => setPreviewCode(template.code)}
onEdit={() =>
navigate(`/dashboard/contract-templates/${template.code}`)
}
onDelete={() => setDeleteTarget(template)}
/>
))}
</SimpleGrid>
@@ -100,22 +142,174 @@ export default function ContractTemplatesPage() {
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
onClose={() => setPreviewCode(null)}
/>
<CreateTemplateModal
opened={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(code) => {
setCreateOpen(false);
navigate(`/dashboard/contract-templates/${code}`);
}}
/>
{/* ── Delete confirm ─────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Delete contract template?"
centered
size="sm"
>
<Stack gap="md">
<Text size="sm">
This will delete{" "}
<Text span fw={600}>
{deleteTarget?.name}
</Text>{" "}
and its articles. Contracts already generated keep their frozen
document; new contracts for this combination fall back to the
generic layout until a new template is created.
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteTemplate.isPending}
onClick={() => {
if (!deleteTarget) return;
deleteTemplate.mutate(deleteTarget.code, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
Delete
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
/**
* Staff pick the customs option first, then a bulk cargo type that has
* "has contract template" enabled. One template per combination — the API
* rejects duplicates, so an existing pairing must be edited instead.
*/
function CreateTemplateModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: (code: string) => void;
}) {
const [withCustoms, setWithCustoms] = useState<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const create = useCreateContractTemplate();
const { data: cargoTypes, isLoading } = useQuery({
queryKey: ["cargo-types", "contract-template-options"],
queryFn: () => cargoTypesService.getCargoTypes(),
enabled: opened,
});
const options = useMemo(
() =>
((cargoTypes ?? []) as CargoTypeOption[])
.filter((cargoType) => cargoType.hasContractTemplate)
.map((cargoType) => ({
value: cargoType.id,
label: cargoType.cargoTypeName ?? "Untitled",
})),
[cargoTypes],
);
const close = () => {
setCargoTypeId(null);
onClose();
};
return (
<Modal opened={opened} onClose={close} title="New bulk contract template" centered>
<Stack gap="md">
<div>
<Text size="sm" fw={500} mb={6}>
Customs clearing
</Text>
<SegmentedControl
fullWidth
value={withCustoms}
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "false", label: "Without customs clearing" },
]}
/>
</div>
<Select
label="Bulk cargo type"
description="Only cargo types with “has contract template” enabled are listed"
placeholder={isLoading ? "Loading…" : "Select a cargo type"}
data={options}
value={cargoTypeId}
onChange={setCargoTypeId}
searchable
nothingFoundMessage="No cargo type allows contract templates yet — enable the flag on the cargo type first"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={close}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!cargoTypeId}
loading={create.isPending}
onClick={() => {
if (!cargoTypeId) return;
create.mutate(
{ cargoTypeId, withCustoms: withCustoms === "true" },
{
onSuccess: (template) =>
onCreated((template as ContractTemplate).code),
},
);
}}
>
Create template
</Button>
</Group>
</Stack>
</Modal>
);
}
function TemplateCard({
template,
canUpdate,
canDelete,
onPreview,
onEdit,
onDelete,
}: {
template: ContractTemplate;
canUpdate: boolean;
canDelete: boolean;
onPreview: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const direction = templateDirection(template.code);
const bulk = isBulk(template.code);
const customs = customsVariant(template.code);
const bulk = isBulk(template);
const direction = template.code.split("_")[0];
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
<Card
@@ -142,12 +336,13 @@ function TemplateCard({
style={{
borderRadius: 999,
flexShrink: 0,
background: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
background: bulk
? "var(--mantine-color-teal-5)"
: DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
{bulk ? "Bulk" : "Container"}
{kicker}
</Text>
</Group>
</Group>
@@ -166,6 +361,18 @@ function TemplateCard({
</Badge>
</Tooltip>
)}
{template.isSystem && (
<Tooltip label="Built-in template — cannot be deleted" withArrow>
<Badge
size="sm"
variant="light"
color="gray"
leftSection={<Lock size={11} />}
>
System
</Badge>
</Tooltip>
)}
{!template.isActive && (
<Tooltip label="Not used for new contracts" withArrow>
<Badge size="sm" variant="light" color="red">
@@ -221,16 +428,34 @@ function TemplateCard({
>
Preview
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
<Group gap={6} wrap="nowrap">
{canDelete && !template.isSystem && (
<Tooltip label="Delete template" withArrow>
<Button
variant="subtle"
color="red"
size="compact-sm"
radius="md"
px={8}
onClick={onDelete}
>
<Trash2 size={14} />
</Button>
</Tooltip>
)}
{canUpdate && (
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="md"
leftSection={<Pencil size={14} />}
onClick={onEdit}
>
Edit articles
</Button>
)}
</Group>
</Group>
</Box>
</Card>

View File

@@ -54,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
requiresDirectorApproval?: boolean;
/** When true, bookings of this cargo type incur the flat LASHING surcharge. */
hasLashing?: boolean;
/** Staff may write bulk contract templates for this cargo type (parent XOR children). */
hasContractTemplate?: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
unitOfMeasure?: string | null;
/** Wagon types that can carry this bulk cargo during scheduling; empty if unset. */
@@ -111,6 +113,10 @@ const FORM_FIELDS: FormFieldDef[] = [
// When on, every booking of this cargo type is charged the flat LASHING
// surcharge (a rate with trigger = Lashing).
{ name: "hasLashing", label: "Charge lashing fee", type: "boolean" },
// Lets staff write bulk contract templates for this cargo type. The API
// rejects the save when the parent group (or a child) already has it on —
// the template must live on exactly one level.
{ name: "hasContractTemplate", label: "Has contract template", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
];
@@ -575,6 +581,13 @@ function CargoRow({
</Badge>
</Tooltip>
) : null}
{node.hasContractTemplate ? (
<Tooltip label="Bulk contract templates are written for this cargo type" withArrow>
<Badge size="xs" variant="light" color="grape" radius="sm">
Contract
</Badge>
</Tooltip>
) : null}
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">

View File

@@ -11,29 +11,33 @@ export interface ContractTemplateArticle {
export interface ContractTemplate {
id: string;
// Import/export split by customs clearing; intercity is domestic, crosses no
// border, and so has a single template.
code:
| "IMPORT_BULK_CUSTOMS"
| "IMPORT_BULK_NO_CUSTOMS"
| "EXPORT_BULK_CUSTOMS"
| "EXPORT_BULK_NO_CUSTOMS"
| "INTERCITY_BULK"
| "IMPORT_CONTAINER_CUSTOMS"
| "IMPORT_CONTAINER_NO_CUSTOMS"
| "EXPORT_CONTAINER_CUSTOMS"
| "EXPORT_CONTAINER_NO_CUSTOMS"
| "INTERCITY_CONTAINER";
// System container templates use the fixed DIRECTION_CONTAINER(_CUSTOMS)
// codes; staff-created bulk templates get generated BULK_<cargo>_* codes.
code: string;
name: string;
description?: string | null;
documentTitle: string;
whereasClauses: string[];
articles: ContractTemplateArticle[];
isActive: boolean;
/** Bulk templates only: the cargo type this template is written for. */
cargoTypeId?: string | null;
cargoType?: { id: string; cargoTypeName: string } | null;
/** Bulk templates only: whether this is the with-customs-clearing variant. */
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateContractTemplatePayload {
cargoTypeId: string;
withCustoms: boolean;
name?: string;
description?: string;
}
export interface UpdateContractTemplatePayload {
name?: string;
description?: string;
@@ -54,6 +58,15 @@ export const contractTemplatesService = {
return data;
},
async create(payload: CreateContractTemplatePayload): Promise<ContractTemplate> {
const { data } = await client.post<ContractTemplate>(BASE, payload);
return data;
},
async remove(code: string): Promise<void> {
await client.delete(`${BASE}/${code}`);
},
async getByCode(code: string): Promise<ContractTemplate> {
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
return data;