mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
merge
This commit is contained in:
@@ -2,17 +2,25 @@ import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
|
||||
import {
|
||||
Boxes,
|
||||
Clock,
|
||||
Container,
|
||||
Eye,
|
||||
FileText,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||
@@ -25,10 +33,10 @@ const DIRECTION_LABEL: Record<string, string> = {
|
||||
INTERCITY: "Intercity",
|
||||
};
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "edr-green",
|
||||
EXPORT: "teal",
|
||||
INTERCITY: "lime",
|
||||
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 templateDirection(code: ContractTemplate["code"]): string {
|
||||
@@ -39,6 +47,14 @@ function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
return new Date(value).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ContractTemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: templates, isLoading } = useContractTemplates();
|
||||
@@ -53,94 +69,20 @@ export default function ContractTemplatesPage() {
|
||||
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>
|
||||
)}
|
||||
<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}
|
||||
onPreview={() => setPreviewCode(template.code)}
|
||||
onEdit={() =>
|
||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<TemplatePreviewModal
|
||||
code={previewCode}
|
||||
@@ -150,3 +92,151 @@ export default function ContractTemplatesPage() {
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
onPreview,
|
||||
onEdit,
|
||||
}: {
|
||||
template: ContractTemplate;
|
||||
onPreview: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
|
||||
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">
|
||||
{DIRECTION_LABEL[direction] ?? direction} ·{" "}
|
||||
{bulk ? "Bulk" : "Container"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<Pencil size={14} />}
|
||||
onClick={onEdit}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,8 +43,18 @@ export interface SaveRoutePayload {
|
||||
* Human-readable route label: yard names, not yard codes. Staff read
|
||||
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
||||
* code only when a yard has no label.
|
||||
*
|
||||
* When milestones are present they ARE the full ordered corridor (origin first,
|
||||
* destination last), so the label shows every stop:
|
||||
* "Addis Ababa → Adama → Dire Dawa".
|
||||
*/
|
||||
export function formatRouteLabel(route: RouteRecord): string {
|
||||
const stops = [...(route.milestones ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yard?.label ?? m.yard?.code)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
if (stops.length >= 2) return stops.join(' → ');
|
||||
|
||||
const origin =
|
||||
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||
const dest =
|
||||
|
||||
Reference in New Issue
Block a user