Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-10 08:54:34 +00:00
139 changed files with 8644 additions and 1557 deletions

View File

@@ -4,18 +4,21 @@ import {
Button,
Card,
Group,
MultiSelect,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
ArrowRight,
Calendar,
CheckCircle2,
Clock,
FilterX,
LayoutList,
Package,
Plus,
@@ -26,6 +29,7 @@ import {
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
@@ -43,6 +47,7 @@ import {
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
import {
@@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [
{ value: "BULK", label: "Bulk" },
];
const PAYMENT_STATUS_OPTIONS = [
{ value: "PENDING", label: "Payment pending" },
{ value: "PNR_GENERATED", label: "PNR generated" },
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
{ value: "PAID", label: "Paid" },
{ value: "FAILED", label: "Payment failed" },
];
const OWNERSHIP_OPTIONS = [
{ value: "true", label: "Government" },
{ value: "false", label: "Private" },
];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
@@ -95,10 +127,18 @@ export default function BookingRequestsPage() {
const [query, setQuery] = useState("");
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter selects (each nullable = "all").
const [statusFilter, setStatusFilter] = useState<string | null>(null);
// Per-tab filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
const [allocateOpen, setAllocateOpen] = useState(false);
const [allocateIds, setAllocateIds] = useState<string[]>([]);
const suppressRowClickRef = useRef(false);
@@ -118,9 +158,21 @@ export default function BookingRequestsPage() {
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
...(statusFilter ? { statuses: statusFilter } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(originYardFilter ? { originYardId: originYardFilter } : {}),
...(destinationYardFilter
? { destinationYardId: destinationYardFilter }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
};
}, [
pagination.pageIndex,
@@ -129,6 +181,14 @@ export default function BookingRequestsPage() {
statusFilter,
directionFilter,
freightTypeFilter,
paymentStatusFilter,
ownershipFilter,
originYardFilter,
destinationYardFilter,
createdFrom,
createdTo,
scheduledFrom,
scheduledTo,
]);
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
@@ -142,6 +202,49 @@ export default function BookingRequestsPage() {
refetch: refetchSummary,
} = useBookingListSummary(filter);
// Yard options for the origin/destination filters (shared routes reference list).
const { data: yardRefs } = useQuery(
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
);
const yardOptions = useMemo(
() =>
(yardRefs ?? []).map((y) => ({
value: y.id,
label: y.label ?? y.code,
})),
[yardRefs],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const activeFilterCount =
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(paymentStatusFilter ? 1 : 0) +
(ownershipFilter ? 1 : 0) +
(originYardFilter ? 1 : 0) +
(destinationYardFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0) +
(scheduledFrom || scheduledTo ? 1 : 0);
const clearFilters = useCallback(() => {
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setPaymentStatusFilter(null);
setOwnershipFilter(null);
setOriginYardFilter(null);
setDestinationYardFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setScheduledFrom(null);
setScheduledTo(null);
resetPage();
}, [resetPage]);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toBookingListRow);
const q = query.trim().toLowerCase();
@@ -415,18 +518,44 @@ export default function BookingRequestsPage() {
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All statuses"
<MultiSelect
placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 200 }}
style={{ minWidth: 220 }}
/>
<Select
placeholder="All origins"
data={yardOptions}
value={originYardFilter}
onChange={(v) => {
setOriginYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All destinations"
data={yardOptions}
value={destinationYardFilter}
onChange={(v) => {
setDestinationYardFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="All directions"
@@ -434,11 +563,11 @@ export default function BookingRequestsPage() {
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
style={{ minWidth: 150 }}
/>
<Select
placeholder="All freight types"
@@ -446,13 +575,98 @@ export default function BookingRequestsPage() {
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 170 }}
style={{ minWidth: 150 }}
/>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All payment statuses"
data={PAYMENT_STATUS_OPTIONS}
value={paymentStatusFilter}
onChange={(v) => {
setPaymentStatusFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 180 }}
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}
value={ownershipFilter}
onChange={(v) => {
setOwnershipFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
/>
<DateInput
placeholder="Scheduled from"
value={scheduledFrom}
onChange={(v) => {
setScheduledFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={scheduledTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
<DateInput
placeholder="Scheduled to"
value={scheduledTo}
onChange={(v) => {
setScheduledTo(v ? new Date(v) : null);
resetPage();
}}
minDate={scheduledFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 150 }}
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
</Stack>
</Box>

View File

@@ -0,0 +1,468 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import {
ActionIcon,
Badge,
Button,
Card,
Center,
Group,
Loader,
Modal,
Paper,
Stack,
Switch,
Text,
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import {
ArrowDown,
ArrowUp,
Pencil,
Plus,
RefreshCw,
Settings2,
Trash2,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import {
useAddArticle,
useContractTemplate,
useContractTemplatePreview,
useRemoveArticle,
useReplaceArticles,
useUpdateArticle,
useUpdateContractTemplate,
} from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
const BODY_HINT =
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
interface ArticleDraft {
id?: string;
title: string;
body: string;
}
export default function ContractTemplateEditorPage() {
const { code } = useParams<{ code: string }>();
const { data: template, isLoading } = useContractTemplate(code);
const preview = useContractTemplatePreview(code);
const updateTemplate = useUpdateContractTemplate(code ?? "");
const addArticle = useAddArticle(code ?? "");
const updateArticle = useUpdateArticle(code ?? "");
const removeArticle = useRemoveArticle(code ?? "");
const replaceArticles = useReplaceArticles(code ?? "");
const [articleDraft, setArticleDraft] = useState<ArticleDraft | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ContractTemplateArticle | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const sortedArticles = useMemo(
() => [...(template?.articles ?? [])].sort((a, b) => a.order - b.order),
[template],
);
const moveArticle = (index: number, delta: -1 | 1) => {
const next = [...sortedArticles];
const target = index + delta;
if (target < 0 || target >= next.length) return;
[next[index], next[target]] = [next[target], next[index]];
replaceArticles.mutate(
next.map(({ id, title, body }) => ({ id, title, body })),
);
};
const saveArticle = () => {
if (!articleDraft) return;
if (articleDraft.id) {
updateArticle.mutate({
articleId: articleDraft.id,
payload: { title: articleDraft.title, body: articleDraft.body },
});
} else {
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
}
setArticleDraft(null);
};
if (isLoading || !template) {
return (
<PageContainer>
<Center h={360}>
<Loader color="edr-green" />
</Center>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title={template.name}
subtitle={template.documentTitle}
backTo="/dashboard/contract-templates"
meta={
<Group gap={6}>
<Badge variant="outline" color="edr-green">
{template.code.replaceAll("_", " · ")}
</Badge>
{!template.isActive && (
<Badge variant="light" color="red">
Inactive
</Badge>
)}
</Group>
}
action={
<Group gap="sm">
<Switch
color="edr-green"
label="Active"
checked={template.isActive}
onChange={(event) =>
updateTemplate.mutate({ isActive: event.currentTarget.checked })
}
/>
<Button
variant="light"
color="edr-green"
leftSection={<Settings2 size={16} />}
onClick={() => setDetailsOpen(true)}
>
Document details
</Button>
<Button
color="edr-green"
leftSection={<Plus size={16} />}
onClick={() => setArticleDraft({ title: "", body: "" })}
>
Add article
</Button>
</Group>
}
/>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{/* ── Article list ─────────────────────────────────────────────── */}
<Stack gap="sm">
{sortedArticles.map((article, index) => (
<Card key={article.id} withBorder radius="lg" padding="md">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-green.7">
Article {index + 1}
</Text>
<Title order={5}>{article.title}</Title>
<Text size="sm" c="dimmed" lineClamp={2} mt={4}>
{article.body}
</Text>
</div>
<Group gap={4} wrap="nowrap">
<Tooltip label="Move up">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === 0}
onClick={() => moveArticle(index, -1)}
>
<ArrowUp size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move down">
<ActionIcon
variant="subtle"
color="gray"
disabled={index === sortedArticles.length - 1}
onClick={() => moveArticle(index, 1)}
>
<ArrowDown size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit article">
<ActionIcon
variant="subtle"
color="edr-green"
onClick={() =>
setArticleDraft({
id: article.id,
title: article.title,
body: article.body,
})
}
>
<Pencil size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="Remove article">
<ActionIcon
variant="subtle"
color="red"
onClick={() => setDeleteTarget(article)}
>
<Trash2 size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Card>
))}
{sortedArticles.length === 0 && (
<Card withBorder radius="lg" padding="xl">
<Center>
<Text c="dimmed">
No articles yet add the first article to build this contract.
</Text>
</Center>
</Card>
)}
</Stack>
{/* ── Live preview ─────────────────────────────────────────────── */}
<Card withBorder radius="lg" padding="sm" className="xl:sticky xl:top-4 self-start">
<Group justify="space-between" mb="xs" px={4}>
<Text fw={600} size="sm">
Document preview (mock data)
</Text>
<Tooltip label="Refresh preview">
<ActionIcon
variant="subtle"
color="edr-green"
loading={preview.isFetching}
onClick={() => void preview.refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
{preview.isLoading ? (
<Center h={480}>
<Loader color="edr-green" />
</Center>
) : (
<iframe
title="Template preview"
srcDoc={preview.data?.html}
style={{
width: "100%",
height: "calc(100vh - 240px)",
minHeight: 480,
border: 0,
background: "#f3f8f5",
}}
/>
)}
</Paper>
</Card>
</div>
{/* ── Add / edit article modal ───────────────────────────────────── */}
<Modal
opened={Boolean(articleDraft)}
onClose={() => setArticleDraft(null)}
title={articleDraft?.id ? "Edit article" : "Add article"}
size="xl"
>
{articleDraft && (
<Stack gap="sm">
<TextInput
label="Article title"
placeholder="e.g. Obligations of the Client"
value={articleDraft.title}
onChange={(event) =>
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
}
required
/>
<Textarea
label="Article body"
description={BODY_HINT}
value={articleDraft.body}
onChange={(event) =>
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
}
autosize
minRows={12}
maxRows={24}
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setArticleDraft(null)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={
articleDraft.title.trim().length < 2 ||
articleDraft.body.trim().length < 2
}
loading={addArticle.isPending || updateArticle.isPending}
onClick={saveArticle}
>
{articleDraft.id ? "Save changes" : "Add article"}
</Button>
</Group>
</Stack>
)}
</Modal>
{/* ── Delete confirm ─────────────────────────────────────────────── */}
<Modal
opened={Boolean(deleteTarget)}
onClose={() => setDeleteTarget(null)}
title="Remove article"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove <strong>{deleteTarget?.title}</strong> from this template? The
remaining articles are renumbered automatically.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={removeArticle.isPending}
onClick={() => {
if (deleteTarget) removeArticle.mutate(deleteTarget.id);
setDeleteTarget(null);
}}
>
Remove article
</Button>
</Group>
</Stack>
</Modal>
{/* ── Document details modal ─────────────────────────────────────── */}
<DocumentDetailsModal
opened={detailsOpen}
onClose={() => setDetailsOpen(false)}
initial={{
name: template.name,
description: template.description ?? "",
documentTitle: template.documentTitle,
whereasClauses: template.whereasClauses,
}}
saving={updateTemplate.isPending}
onSave={(values) => {
updateTemplate.mutate(values);
setDetailsOpen(false);
}}
/>
</PageContainer>
);
}
interface DocumentDetailsModalProps {
opened: boolean;
onClose: () => void;
initial: {
name: string;
description: string;
documentTitle: string;
whereasClauses: string[];
};
saving: boolean;
onSave: (values: {
name: string;
description: string;
documentTitle: string;
whereasClauses: string[];
}) => void;
}
function DocumentDetailsModal({
opened,
onClose,
initial,
saving,
onSave,
}: DocumentDetailsModalProps) {
const [name, setName] = useState(initial.name);
const [description, setDescription] = useState(initial.description);
const [documentTitle, setDocumentTitle] = useState(initial.documentTitle);
const [whereas, setWhereas] = useState(initial.whereasClauses.join("\n\n"));
// Re-sync local state each time the modal opens with fresh server data.
const [lastOpened, setLastOpened] = useState(false);
if (opened && !lastOpened) {
setName(initial.name);
setDescription(initial.description);
setDocumentTitle(initial.documentTitle);
setWhereas(initial.whereasClauses.join("\n\n"));
setLastOpened(true);
} else if (!opened && lastOpened) {
setLastOpened(false);
}
return (
<Modal opened={opened} onClose={onClose} title="Document details" size="xl">
<Stack gap="sm">
<TextInput
label="Template name"
value={name}
onChange={(event) => setName(event.currentTarget.value)}
required
/>
<TextInput
label="Cover page title"
description="Printed on the contract cover, e.g. “Import Container Transport Service by Railway”."
value={documentTitle}
onChange={(event) => setDocumentTitle(event.currentTarget.value)}
required
/>
<Textarea
label="Card description"
description="Shown on the Templates page card only — not printed."
value={description}
onChange={(event) => setDescription(event.currentTarget.value)}
autosize
minRows={2}
/>
<Textarea
label="Recitals (WHEREAS clauses)"
description="One recital per paragraph — separate recitals with a blank line."
value={whereas}
onChange={(event) => setWhereas(event.currentTarget.value)}
autosize
minRows={5}
maxRows={12}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
color="edr-green"
loading={saving}
disabled={name.trim().length < 3 || documentTitle.trim().length < 3}
onClick={() =>
onSave({
name: name.trim(),
description: description.trim(),
documentTitle: documentTitle.trim(),
whereasClauses: whereas
.split(/\n\s*\n/)
.map((clause) => clause.trim())
.filter(Boolean),
})
}
>
Save details
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,152 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Button,
Card,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
import type { ContractTemplate } from "@/services/contract-templates.service";
import TemplatePreviewModal from "./TemplatePreviewModal";
const DIRECTION_LABEL: Record<string, string> = {
IMPORT: "Import",
EXPORT: "Export",
INTERCITY: "Intercity",
};
const DIRECTION_COLOR: Record<string, string> = {
IMPORT: "edr-green",
EXPORT: "teal",
INTERCITY: "lime",
};
function templateDirection(code: ContractTemplate["code"]): string {
return code.split("_")[0];
}
function isBulk(code: ContractTemplate["code"]): boolean {
return code.endsWith("_BULK");
}
export default function ContractTemplatesPage() {
const navigate = useNavigate();
const { data: templates, isLoading } = useContractTemplates();
const [previewCode, setPreviewCode] = useState<string | null>(null);
const previewTemplate = templates?.find((t) => t.code === previewCode);
return (
<PageContainer>
<PageHeader
title="Contract templates"
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>
)}
<TemplatePreviewModal
code={previewCode}
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
onClose={() => setPreviewCode(null)}
/>
</PageContainer>
);
}

View File

@@ -0,0 +1,49 @@
import { Center, Loader, Modal, Paper, Text } from "@mantine/core";
import { useContractTemplatePreview } from "@/hooks/contract-templates/useContractTemplates";
interface TemplatePreviewModalProps {
code: string | null;
title?: string;
onClose: () => void;
}
/** Full-document HTML preview rendered by the API against mock contract data. */
export default function TemplatePreviewModal({
code,
title,
onClose,
}: TemplatePreviewModalProps) {
const { data, isLoading, isError } = useContractTemplatePreview(
code ?? undefined,
Boolean(code),
);
return (
<Modal
opened={Boolean(code)}
onClose={onClose}
title={title ?? "Contract preview"}
size="90%"
padding="sm"
>
{isLoading ? (
<Center h={420}>
<Loader color="edr-green" />
</Center>
) : isError ? (
<Center h={200}>
<Text c="red">Failed to render the preview.</Text>
</Center>
) : (
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
<iframe
title="Contract template preview"
srcDoc={data?.html}
style={{ width: "100%", height: "72vh", border: 0, background: "#f3f8f5" }}
/>
</Paper>
)}
</Modal>
);
}

View File

@@ -302,7 +302,7 @@ export default function ContractRequestDetailPage() {
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—");
: (contract.company?.name ?? "—");
return (
<PageContainer>

View File

@@ -133,21 +133,21 @@ export default function ContractRequestsPage() {
const columns: ColumnDef<ContractListRow>[] = [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<FileText className="size-4" strokeWidth={1.75} />
<User className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.reference}
{c.customerLabel}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{c.customerLabel}
<FileText className="size-3 shrink-0 opacity-70" />
{c.reference}
</p>
</div>
</div>

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
@@ -16,10 +17,15 @@ import {
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
ArrowDownWideNarrow,
ArrowRight,
ArrowUpNarrowWide,
CalendarClock,
CalendarDays,
Eye,
@@ -47,9 +53,31 @@ import {
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import type { BatchBoardSchedule } from "@/types/trainScheduling";
import type {
BatchBoardFilters,
BatchBoardSchedule,
BatchBoardSortField,
TrainScheduleStatus,
} from "@/types/trainScheduling";
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
DRAFT: { color: "gray", label: "Draft" },
SCHEDULED: { color: "blue", label: "Scheduled" },
DISPATCHED: { color: "orange", label: "Dispatched" },
ARRIVED: { color: "green", label: "Arrived" },
CANCELLED: { color: "red", label: "Cancelled" },
};
function StatusBadge({ status }: { status: string }) {
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
return (
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
);
}
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
@@ -237,7 +265,8 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
c="dimmed"
style={{ letterSpacing: 0.8 }}
>
Freight schedule · {schedule.status}
{schedule.scheduleReference ?? "Freight schedule"} ·{" "}
{schedule.status}
</Text>
</Box>
</Group>
@@ -395,15 +424,73 @@ function CardSkeleton() {
export default function BatchBoardPage() {
const navigate = useNavigate();
const { data, isLoading, isError, isFetching, refetch } = useQuery(
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
);
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { pagination, setPagination } = usePagination({ pageSize: 12 });
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, 300);
const [statusFilter, setStatusFilter] = useState("ALL");
const [windowFilter, setWindowFilter] = useState("ALL");
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
const [departureTo, setDepartureTo] = useState<Date | null>(null);
const schedules = data ?? [];
// Every knob maps straight onto the server-side batch-board query — the API
// filters, searches, sorts and paginates; this page just renders the page.
const filters = useMemo((): BatchBoardFilters => {
const endOfDay = (d: Date) =>
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedSearch.trim() || undefined,
statuses:
statusFilter === "ALL"
? undefined
: [statusFilter as TrainScheduleStatus],
bookingWindowStatus:
windowFilter === "ALL"
? undefined
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
sortBy,
sortOrder,
};
}, [
pagination,
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
]);
// Any filter change restarts from the first page.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [
debouncedSearch,
statusFilter,
windowFilter,
departureFrom,
departureTo,
sortBy,
sortOrder,
setPagination,
]);
const { data, isLoading, isError, isFetching, refetch } = useQuery({
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
refetchInterval: 30_000,
placeholderData: keepPreviousData,
});
const schedules = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = data?.totalPages ?? 1;
const summary = useMemo(() => {
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
@@ -412,33 +499,6 @@ export default function BatchBoardPage() {
return { openWindows, totalBookings, totalWagons };
}, [schedules]);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return schedules.filter((s) => {
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
if (!query) return true;
const haystack = [
s.trainNumber,
s.routeName,
s.origin,
s.destination,
s.locomotive?.code,
s.status,
s.bookingWindowStatus,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [schedules, search, windowFilter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return filtered.slice(start, start + pagination.pageSize);
}, [filtered, pagination]);
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
@@ -456,6 +516,11 @@ export default function BatchBoardPage() {
<Text size="sm" fw={700} lh={1.2} truncate>
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
</Text>
{row.original.scheduleReference ? (
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
{row.original.scheduleReference}
</Text>
) : null}
<Box maw={220}>
<RouteCorridor
origin={row.original.origin}
@@ -502,6 +567,30 @@ export default function BatchBoardPage() {
);
},
},
{
id: "status",
header: "Status",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "created",
header: "Created",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const { day, time } = splitDate(row.original.createdAt);
return (
<Stack gap={0}>
<Text size="sm" fw={600} lh={1.2}>
{day}
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{time || "—"}
</Text>
</Stack>
);
},
},
{
id: "window",
header: "Window",
@@ -623,7 +712,7 @@ export default function BatchBoardPage() {
<PageContainer>
<PageHeader
title="Batch Board"
subtitle="Active schedules and their booking windows."
subtitle="All import schedules — live and historical — with their booking windows."
action={
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
Refresh
@@ -635,21 +724,21 @@ export default function BatchBoardPage() {
loading={isLoading}
items={[
{
label: "Active schedules",
value: schedules.length,
hint: "on the board right now",
label: "Schedules",
value: total,
hint: "matching the current filters",
icon: Train,
},
{
label: "Open windows",
value: summary.openWindows,
hint: "accepting bookings",
hint: "accepting bookings (this page)",
icon: CalendarDays,
},
{
label: "Bookings in play",
value: summary.totalBookings,
hint: `${summary.totalWagons} wagons allocated`,
hint: `${summary.totalWagons} wagons allocated (this page)`,
icon: Package,
},
]}
@@ -661,24 +750,96 @@ export default function BatchBoardPage() {
<FleetToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search schedules…"
searchPlaceholder="Search train, route, station, loco…"
viewMode={viewMode}
onViewModeChange={setViewMode}
filters={
<Select
size="sm"
radius="lg"
value={windowFilter}
onChange={(v) => v && setWindowFilter(v)}
data={[
{ value: "ALL", label: "All windows" },
{ value: "OPEN", label: "Open" },
{ value: "FULL", label: "Full" },
{ value: "CLOSED", label: "Closed" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Group gap="xs" wrap="wrap">
<Select
size="sm"
radius="lg"
value={statusFilter}
onChange={(v) => v && setStatusFilter(v)}
data={[
{ value: "ALL", label: "All statuses" },
{ value: "DRAFT", label: "Draft" },
{ value: "SCHEDULED", label: "Scheduled" },
{ value: "DISPATCHED", label: "Dispatched" },
{ value: "ARRIVED", label: "Arrived" },
{ value: "CANCELLED", label: "Cancelled" },
]}
w={150}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={windowFilter}
onChange={(v) => v && setWindowFilter(v)}
data={[
{ value: "ALL", label: "All windows" },
{ value: "OPEN", label: "Open" },
{ value: "FULL", label: "Full" },
{ value: "CLOSED", label: "Closed" },
]}
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs from"
value={departureFrom}
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<DateInput
size="sm"
radius="lg"
placeholder="Departs to"
value={departureTo}
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
clearable
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={sortBy}
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
data={[
{ value: "createdAt", label: "Sort: Created" },
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
{ value: "trainNumber", label: "Sort: Train no." },
{ value: "status", label: "Sort: Status" },
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Tooltip
label={sortOrder === "DESC" ? "Newest / ZA first" : "Oldest / AZ first"}
withArrow
>
<ActionIcon
variant="default"
size={36}
radius="lg"
aria-label="Toggle sort direction"
onClick={() =>
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
}
>
{sortOrder === "DESC" ? (
<ArrowDownWideNarrow size={16} />
) : (
<ArrowUpNarrowWide size={16} />
)}
</ActionIcon>
</Tooltip>
</Group>
}
/>
</Box>
@@ -686,7 +847,7 @@ export default function BatchBoardPage() {
{viewMode === "table" ? (
<DataTable
columns={columns}
data={paged}
data={schedules}
status={tableStatus}
onRowClick={(schedule) =>
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
@@ -699,12 +860,12 @@ export default function BatchBoardPage() {
}
: undefined
}
emptyMessage="No active schedules"
emptyMessage="No schedules match the current filters"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: filtered.length,
totalCount: total,
}}
tableOptions={{
manualPagination: true,
@@ -727,7 +888,7 @@ export default function BatchBoardPage() {
<CardSkeleton />
<CardSkeleton />
</SimpleGrid>
) : filtered.length === 0 ? (
) : schedules.length === 0 ? (
<Paper radius="lg" p={48} m="md" bg="gray.0">
<Stack align="center" gap="sm">
<Box
@@ -745,20 +906,52 @@ export default function BatchBoardPage() {
<Inbox size={28} color="var(--mantine-color-gray-5)" />
</Box>
<Text fw={700} c="gray.7">
No active schedules
No schedules match the current filters
</Text>
<Text size="sm" c="dimmed" ta="center" maw={380}>
Schedules with an open booking window appear here. Create or activate a
schedule to get started.
Every import schedule live and historical appears here. Loosen the
filters or clear the search to see more.
</Text>
</Stack>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
{filtered.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
<>
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
{schedules.map((s) => (
<ScheduleCard key={s.scheduleId} schedule={s} />
))}
</SimpleGrid>
<Group justify="space-between" px="md" pb="md">
<Text size="sm" c="dimmed">
{total} schedule{total === 1 ? "" : "s"}
</Text>
<Group gap="xs">
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex === 0}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
}
>
Previous
</Button>
<Text size="sm" c="dimmed">
Page {pagination.pageIndex + 1} of {pageCount}
</Text>
<Button
variant="default"
size="xs"
disabled={pagination.pageIndex + 1 >= pageCount}
onClick={() =>
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
}
>
Next
</Button>
</Group>
</Group>
</>
)}
</Stack>
</Card>

View File

@@ -20,12 +20,12 @@ import {
AlertTriangle,
ArrowLeft,
ArrowLeftRight,
Boxes,
CalendarDays,
CheckCircle2,
ClipboardCheck,
Clock,
FileSignature,
Hash,
Hourglass,
Layers,
Package,
@@ -622,6 +622,9 @@ export default function BatchScheduleDetailPage() {
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}),
);
@@ -745,7 +748,13 @@ export default function BatchScheduleDetailPage() {
items={[
{ label: "Operations" },
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
{
label:
data.scheduleReference ??
data.trainNumber ??
data.routeName ??
"Schedule",
},
]}
/>
@@ -792,6 +801,11 @@ export default function BatchScheduleDetailPage() {
<Title order={2} fw={800}>
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
{data.scheduleReference ? (
<HeroChip icon={<Hash size={12} />}>
{data.scheduleReference}
</HeroChip>
) : null}
<WindowStatusPill status={data.bookingWindowStatus} />
{data.windowPhase ? (
<WindowPhasePill
@@ -890,14 +904,6 @@ export default function BatchScheduleDetailPage() {
<KpiStrip
items={[
{
label: "Allocated wagons",
value: data.capacity.maxWagons
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
: data.capacity.allocatedWagons,
hint: "on this train",
icon: Boxes,
},
{
label: "Train length",
value: data.capacity.maxLengthMeters
@@ -1111,7 +1117,7 @@ export default function BatchScheduleDetailPage() {
<TrainCompositionDiagram
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
freightType="CONTAINER"
freightType={scheduleDetailQuery.data.freightType ?? null}
trainNumber={scheduleDetailQuery.data.trainNumber}
totalLengthMeters={
scheduleDetailQuery.data.trainSet?.totalLengthMeters
@@ -1133,7 +1139,7 @@ export default function BatchScheduleDetailPage() {
<TrainConsistView
scheduleDetail={scheduleDetailQuery.data}
scheduleId={scheduleId ?? ""}
maxWagons={53}
maxWagons={data.capacity.maxWagons ?? 53}
highlightBookingId={selectedBookingId}
/>
</Box>