mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
feat(contracts): per-cargo bulk contract templates
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user