mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
580 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|