Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx
Marshal b926a3116e feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
2026-08-25 21:44:21 +00:00

580 lines
18 KiB
TypeScript

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,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Boxes,
Clock,
Container,
Eye,
FileText,
Lock,
Pencil,
Plus,
Trash2,
} from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { PageContainer, PageHeader } from "@/components/page";
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 {
BulkTemplateDirection,
ContractTemplate,
} from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
const DIRECTION_LABEL: Record<string, string> = {
IMPORT: "Import",
EXPORT: "Export",
INTERCITY: "Intercity",
};
const DIRECTION_DOT: Record<string, string> = {
IMPORT: "var(--mantine-color-blue-5)",
EXPORT: "var(--mantine-color-violet-5)",
INTERCITY: "var(--mantine-color-orange-5)",
};
function isBulk(template: ContractTemplate): boolean {
return Boolean(template.cargoTypeId);
}
// 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 "WITHOUT", which would wrongly read as a deliberate "client
// clears its own customs" choice. "ETHIOPIAN" is the with-customs variant
// restricted to Ethiopian-side clearing (Djibouti stays with the client).
type CustomsVariant = "WITH" | "WITHOUT" | "ETHIOPIAN" | null;
function customsVariant(template: ContractTemplate): CustomsVariant {
if (isBulk(template)) {
if (template.withCustoms == null) return null;
if (!template.withCustoms) return "WITHOUT";
return template.ethiopianCustomsOnly ? "ETHIOPIAN" : "WITH";
}
if (template.code.endsWith("_NO_CUSTOMS")) return "WITHOUT";
if (template.code.endsWith("_ETHIOPIAN_CUSTOMS")) return "ETHIOPIAN";
if (template.code.endsWith("_CUSTOMS")) return "WITH";
return null;
}
const CUSTOMS_BADGE: Record<
Exclude<CustomsVariant, null>,
{ label: string; color: string; tooltip: string }
> = {
WITH: {
label: "With customs",
color: "teal",
tooltip: "Used when the contract has customs clearing enabled",
},
ETHIOPIAN: {
label: "Ethiopian customs",
color: "indigo",
tooltip:
"Used when the service type includes Ethiopian customs clearing only — Djibouti clearing stays with the client",
},
WITHOUT: {
label: "No customs",
color: "gray",
tooltip: "Used when the client handles its own customs clearing",
},
};
// Bulk templates carry the direction on the row; the fixed container codes
// carry it as the code prefix.
function directionOf(template: ContractTemplate): string {
return isBulk(template)
? template.tradeDirection ?? "INTERCITY"
: template.code.split("_")[0];
}
function formatUpdated(value: string): string {
return new Date(value).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
});
}
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);
return (
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The container contract documents are built in — one per trade direction and customs-clearing option (with customs, Ethiopian customs only, without). Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. 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: 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>
<TemplatePreviewModal
code={previewCode}
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 trade direction, then the customs option, then a bulk cargo
* type that has "has contract template" enabled. One template per
* (direction, customs, cargo type) combination — the API rejects duplicates,
* so an existing combination must be edited instead.
*
* Intercity is domestic and crosses no border, so the customs choice does not
* apply there and is hidden.
*/
function CreateTemplateModal({
opened,
onClose,
onCreated,
}: {
opened: boolean;
onClose: () => void;
onCreated: (code: string) => void;
}) {
const [direction, setDirection] = useState<BulkTemplateDirection>("IMPORT");
const [withCustoms, setWithCustoms] = useState<string>("true");
const [cargoTypeId, setCargoTypeId] = useState<string | null>(null);
const create = useCreateContractTemplate();
const intercity = direction === "INTERCITY";
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}>
Trade direction
</Text>
<SegmentedControl
fullWidth
value={direction}
onChange={(value) => setDirection(value as BulkTemplateDirection)}
data={[
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "INTERCITY", label: "Intercity" },
]}
/>
</div>
{intercity ? (
<Text size="xs" c="dimmed">
Intercity contracts are domestic and cross no border, so they have
no customs clearing variant one template per cargo type.
</Text>
) : (
<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: "ethiopian", label: "Ethiopian customs only" },
{ value: "false", label: "Without customs clearing" },
]}
/>
{withCustoms === "ethiopian" && (
<Text size="xs" c="dimmed" mt={6}>
Used for service types marked Ethiopian customs only: the
Service Provider clears the Ethiopian side, Djibouti clearing
stays with the client.
</Text>
)}
</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,
tradeDirection: direction,
// Omitted for intercity — the API rejects the flags there.
...(intercity
? {}
: {
withCustoms: withCustoms !== "false",
...(withCustoms === "ethiopian"
? { ethiopianCustomsOnly: 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 bulk = isBulk(template);
const direction = directionOf(template);
const customs = customsVariant(template);
const kicker = bulk
? `${template.cargoType?.cargoTypeName ?? "Bulk cargo"} · ${
DIRECTION_LABEL[direction] ?? direction
} · Bulk`
: `${DIRECTION_LABEL[direction] ?? direction} · Container`;
return (
<Card
withBorder
radius="lg"
padding={0}
className="group flex flex-col overflow-hidden transition-all duration-150 hover:-translate-y-0.5 hover:shadow-md"
>
<Stack gap="md" p="lg" style={{ flex: 1 }}>
{/* Kicker row: muted icon well + category label + state */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={40} radius="md" variant="light" color="gray" c="gray.6">
{bulk ? (
<Boxes size={20} strokeWidth={1.75} />
) : (
<Container size={20} strokeWidth={1.75} />
)}
</ThemeIcon>
<Group gap={7} wrap="nowrap">
<Box
w={7}
h={7}
style={{
borderRadius: 999,
flexShrink: 0,
background:
DIRECTION_DOT[direction] ?? "var(--mantine-color-gray-5)",
}}
/>
<Text size="xs" fw={600} tt="uppercase" lts="0.06em" c="dimmed">
{kicker}
</Text>
</Group>
</Group>
<Group gap={6} wrap="nowrap">
{customs !== null && (
<Tooltip label={CUSTOMS_BADGE[customs].tooltip} withArrow>
<Badge size="sm" variant="light" color={CUSTOMS_BADGE[customs].color}>
{CUSTOMS_BADGE[customs].label}
</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">
Inactive
</Badge>
</Tooltip>
)}
</Group>
</Group>
{/* Name + description */}
<div>
<Text fw={600} size="md" lh={1.35}>
{template.name}
</Text>
<Text size="sm" c="dimmed" lineClamp={2} mt={4} lh={1.5}>
{template.description || template.documentTitle}
</Text>
</div>
{/* Meta stats */}
<Group gap="lg" mt="auto">
<Group gap={5} wrap="nowrap">
<FileText size={13} className="text-gray-400" />
<Text size="xs" c="dimmed">
{template.articles.length} article
{template.articles.length !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap={5} wrap="nowrap">
<Clock size={13} className="text-gray-400" />
<Text size="xs" c="dimmed">
Updated {formatUpdated(template.updatedAt)}
</Text>
</Group>
</Group>
</Stack>
{/* Footer actions, separated by a hairline */}
<Box
px="md"
py="xs"
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
>
<Group justify="space-between">
<Button
variant="subtle"
color="gray"
size="compact-sm"
radius="md"
leftSection={<Eye size={14} />}
onClick={onPreview}
>
Preview
</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>
);
}
function TemplateCardSkeleton() {
return (
<Card withBorder radius="lg" padding={0} className="overflow-hidden">
<Stack gap="md" p="lg">
<Group gap="sm">
<Skeleton height={40} width={40} radius="md" />
<Skeleton height={10} width={120} radius="xl" />
</Group>
<div>
<Skeleton height={14} width="70%" radius="xl" />
<Skeleton height={10} width="95%" radius="xl" mt={10} />
<Skeleton height={10} width="60%" radius="xl" mt={6} />
</div>
<Group gap="lg">
<Skeleton height={10} width={70} radius="xl" />
<Skeleton height={10} width={110} radius="xl" />
</Group>
</Stack>
<Box
px="md"
py="xs"
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
>
<Group justify="space-between">
<Skeleton height={26} width={90} radius="md" />
<Skeleton height={26} width={110} radius="md" />
</Group>
</Box>
</Card>
);
}