This commit is contained in:
natib21
2026-07-03 15:22:51 +00:00
209 changed files with 13780 additions and 2892 deletions

View File

@@ -29,6 +29,7 @@ import {
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
@@ -85,6 +86,11 @@ export default function GlClearanceDetailPage() {
enabled: Boolean(id),
});
const linkedBookingId =
data?.kind === "contract" ? (data.clearance.linkedBookingId ?? undefined) : undefined;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
useBookingMilestones(linkedBookingId);
if (isLoading) {
return (
<PageContainer>
@@ -197,7 +203,13 @@ export default function GlClearanceDetailPage() {
<Grid.Col span={{ base: 12, lg: 5 }}>
<PhasedClearanceActionPanel
contractId={data.kind === "contract" ? id : undefined}
bookingId={data.kind === "booking" ? id : undefined}
bookingId={data.kind === "booking" ? id : linkedBookingId}
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
bookingMilestones={
data.kind === "booking"
? (data.clearance.milestones ?? [])
: (bookingMilestones ?? [])
}
clearance={data.clearance}
tradeDirection={data.tradeDirection}
workflowFiles={workflowFiles}
@@ -205,7 +217,10 @@ export default function GlClearanceDetailPage() {
useUploadModals
onUploadDoRequest={() => setUploadKind("do")}
onUploadRoRequest={() => setUploadKind("ro")}
onChanged={() => void refetch()}
onChanged={() => {
void refetch();
void refetchBookingMilestones();
}}
onViewFile={view}
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
/>

View File

@@ -1,30 +1,172 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
import { ChevronRight, Container, Ship } from "lucide-react";
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Tabs,
Text,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
import {
useDjClearanceQueue,
useDjClearanceSchedules,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
const schedulesQuery = useDjClearanceSchedules();
const contractItems = contractQueue?.items ?? [];
const bookingItems = bookingQueue ?? [];
const scheduleItems = schedulesQuery.data ?? [];
const [gatepassTarget, setGatepassTarget] =
useState<Freight.DjClearanceSchedule | null>(null);
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
const [granting, setGranting] = useState(false);
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
() => [
{
header: "Train",
accessorKey: "trainNumber",
cell: ({ row }) => (
<Text size="sm" fw={700}>
{row.original.trainNumber ?? "—"}
</Text>
),
},
{
header: "Route",
id: "route",
cell: ({ row }) => (
<Text size="sm">
{row.original.origin ?? "—"} {row.original.destination ?? "—"}
</Text>
),
},
{
header: "Scheduled departure",
id: "scheduled",
cell: ({ row }) => (
<Text size="sm">
{row.original.scheduledDepartureDate
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
: "—"}
</Text>
),
},
{
header: "Departed",
id: "departed",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualDepartureAt
? new Date(row.original.actualDepartureAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Arrived",
id: "arrived",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualArrivalAt
? new Date(row.original.actualArrivalAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Status",
accessorKey: "status",
cell: ({ row }) => (
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
{row.original.status}
</Badge>
),
},
{
header: "Customs bookings",
id: "customs",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green" radius="sm">
{bookings.length}
</Badge>
{directions.map((d) => (
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
{d}
</Badge>
))}
</Group>
);
},
},
{
header: "Gate pass",
id: "gatepass",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const allGranted =
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
if (allGranted) {
return (
<Badge variant="light" color="edr-green" radius="sm">
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
</Badge>
);
}
return (
<Button
size="xs"
color="edr-green"
leftSection={<Truck size={14} />}
onClick={(e) => {
e.stopPropagation();
setGatepassAt(new Date());
setGatepassTarget(row.original);
}}
>
Gate pass
</Button>
);
},
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
/>
<Tabs defaultValue="contracts" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
<Tabs.Tab value="bookings">Bookings ({bookingItems.length})</Tabs.Tab>
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
Schedules ({scheduleItems.length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="contracts">
@@ -36,8 +178,7 @@ export default function GlDjiboutiClearanceListPage() {
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet. Items appear here once Ethiopia-side
pre-clearance is finalized.
No Djibouti customs contracts yet.
</Text>
) : (
contractItems.map((c) => (
@@ -73,51 +214,113 @@ export default function GlDjiboutiClearanceListPage() {
)}
</Tabs.Panel>
<Tabs.Panel value="bookings">
{bookingsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{bookingItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs bookings yet.
</Text>
) : (
bookingItems.map((b) => (
<Card
key={b.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Container size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{b.reference}</Text>
<Text size="sm" c="dimmed">
{b.tradeDirection} · {b.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="blue">
Booking
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
<Tabs.Panel value="schedules">
<DataTable
columns={columns}
data={scheduleItems}
status={
schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success"
}
error={
schedulesQuery.isError
? {
message: "Failed to load train schedules.",
onRetry: () => void schedulesQuery.refetch(),
}
: undefined
}
emptyMessage="No train schedules carry customs bookings yet."
/>
</Tabs.Panel>
</Tabs>
<Modal
opened={gatepassTarget != null}
onClose={() => setGatepassTarget(null)}
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>
Gate pass train {gatepassTarget?.trainNumber ?? ""}
</Text>
</Group>
}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Grants the gate pass for all{" "}
{gatepassTarget?.customsBookings.length ?? 0} customs booking
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
train.
</Text>
<DateTimePicker
label="Gate pass time"
value={gatepassAt}
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => setGatepassTarget(null)}
disabled={granting}
>
Cancel
</Button>
<Button
color="edr-green"
loading={granting}
leftSection={<Truck size={16} />}
onClick={async () => {
if (!gatepassTarget) return;
setGranting(true);
try {
const result = await contractsService.grantScheduleGatepass(
gatepassTarget.id,
(gatepassAt ?? new Date()).toISOString(),
);
if (result.skipped.length > 0) {
toast.error(
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
);
} else {
toast.success(
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
);
}
setGatepassTarget(null);
void schedulesQuery.refetch();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setGranting(false);
}
}}
>
Grant gate pass
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}
function statusColor(status: string): string {
switch (status) {
case "SCHEDULED":
return "blue";
case "DISPATCHED":
return "yellow";
case "ARRIVED":
return "edr-green";
default:
return "gray";
}
}

View File

@@ -1,5 +1,6 @@
import {
ActionIcon,
Anchor,
Box,
Button,
Card,
@@ -17,10 +18,13 @@ import {
ArrowRight,
Banknote,
Download,
Eye,
FileText,
IdCard,
LayoutGrid,
Package,
Paperclip,
Receipt,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
@@ -30,6 +34,7 @@ import {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,
@@ -50,7 +55,13 @@ import type {
CustomerDocument,
CustomerPayment,
} from "@/types/customer";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
useFileViewer,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
@@ -78,6 +89,7 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { view, viewer } = useFileViewer();
const { data: company, isLoading } = useQuery(
api.customers.getById.queryOptions({
@@ -104,9 +116,34 @@ export default function CustomerDetailPage() {
}),
);
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
usePagination({
pageSize: 10,
});
const invoiceFilter = useMemo(
() => ({
companyId: id ?? "",
page: invoicePagination.pageIndex + 1,
pageSize: invoicePagination.pageSize,
}),
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
);
const invoicesQuery = useQuery(
api.invoices.list.queryOptions({
input: { filter: invoiceFilter },
enabled: Boolean(id),
}),
);
const bookings = bookingsQuery.data ?? [];
const documents = documentsQuery.data ?? [];
const payments = paymentsQuery.data ?? [];
const invoices = invoicesQuery.data?.items ?? [];
const invoiceTotal = invoicesQuery.data?.total ?? 0;
const invoicePageCount = Math.max(
1,
Math.ceil(invoiceTotal / invoicePagination.pageSize),
);
const totalPaid = useMemo(
() =>
@@ -285,20 +322,37 @@ export default function CustomerDetailPage() {
header: "",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<ActionIcon
component="a"
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"
data-stop-row-click
>
<Download size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
aria-label="View"
data-stop-row-click
onClick={() =>
view({
name: row.original.name,
url: fileViewUrl(row.original.id),
mimeType: row.original.mimeType,
})
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="a"
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"
data-stop-row-click
>
<Download size={16} />
</ActionIcon>
</Group>
),
},
],
[],
[view],
);
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
@@ -358,6 +412,59 @@ export default function CustomerDetailPage() {
[],
);
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "dueAt",
header: "Due",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.dueAt)}
</Text>
),
},
],
[],
);
const licenseProfiles = (company?.companyProfiles ?? []).filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
if (isLoading) {
return (
<Center mih="60vh">
@@ -392,8 +499,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
}`}
subtitle={`TIN ${company.tin}${
company.country ? ` · ${company.country}` : ""
}`}
meta={
<Group gap="xs" wrap="nowrap">
<CompanyTypeBadge type={company.type} />
@@ -416,6 +524,9 @@ export default function CustomerDetailPage() {
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
Payments
</Tabs.Tab>
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
</Tabs.List>
{/* OVERVIEW */}
@@ -528,9 +639,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
message: "Failed to load bookings.",
onRetry: () => void bookingsQuery.refetch(),
}
: undefined
}
/>
@@ -539,23 +650,63 @@ export default function CustomerDetailPage() {
{/* DOCUMENTS */}
<Tabs.Panel value="documents" pt="lg">
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
data={documents}
status={tableStatus(documentsQuery)}
emptyMessage="No documents uploaded."
containerClassName="border-0 shadow-none bg-transparent"
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
<Stack gap="lg">
<TableCard minWidth={760}>
<DataTable
columns={documentColumns}
data={documents}
status={tableStatus(documentsQuery)}
emptyMessage="No documents uploaded."
containerClassName="border-0 shadow-none bg-transparent"
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
{licenseProfiles.length > 0 && (
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Business licenses
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Text size="sm" fw={600} c="edr-text">
{humanize(p.type)} · {p.reference}
</Text>
{(p.licenseFiles ?? []).map((f) => (
<Group key={f.url} gap={6} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
type="button"
onClick={() =>
view({
name: f.name,
url: f.url,
mimeType: f.mimeType,
})
}
size="xs"
>
{f.name}
</Anchor>
</Group>
))}
</Stack>
))}
</Stack>
</Stack>
</Card>
)}
</Stack>
</Tabs.Panel>
{/* PAYMENTS */}
@@ -570,15 +721,53 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
message: "Failed to load payments.",
onRetry: () => void paymentsQuery.refetch(),
}
: undefined
}
/>
</TableCard>
</Tabs.Panel>
{/* INVOICES */}
<Tabs.Panel value="invoices" pt="lg">
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<DataTable
columns={invoiceColumns}
data={invoices}
status={tableStatus(invoicesQuery)}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage="No invoices for this customer."
containerClassName="border-0 shadow-none bg-transparent"
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{
pageIndex: invoicePagination.pageIndex,
pageSize: invoicePagination.pageSize,
pageCount: invoicePageCount,
totalCount: invoiceTotal,
}}
tableOptions={{
state: { pagination: invoicePagination },
onPaginationChange: setInvoicePagination,
manualPagination: true,
pageCount: invoicePageCount,
}}
/>
</Box>
</Box>
</Tabs.Panel>
</Tabs>
{viewer}
</PageContainer>
);
}

View File

@@ -0,0 +1,254 @@
import {
ActionIcon,
Button,
Card,
Center,
Container,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Download } from "lucide-react";
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
InvoiceStatusBadge,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import { invoicesService } from "@/services/invoices.service";
function openPdfBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const opened = window.open(url, "_blank");
if (!opened) {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
function InfoField({ label, value }: { label: string; value?: string | null }) {
return (
<Stack gap={2}>
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
{label}
</Text>
<Text size="sm" c="edr-text">
{value && value.trim() ? value : "—"}
</Text>
</Stack>
);
}
export default function InvoiceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [downloading, setDownloading] = useState(false);
const { data: invoice, isLoading } = useQuery(
api.invoices.getById.queryOptions({
input: { id: id ?? "" },
enabled: Boolean(id),
}),
);
const downloadDocument = async () => {
if (!id) return;
setDownloading(true);
try {
const { data } = await invoicesService.downloadDocument(id);
openPdfBlob(data, `${invoice?.invoiceNumber ?? "invoice"}.pdf`);
} finally {
setDownloading(false);
}
};
if (isLoading) {
return (
<Center mih="60vh">
<Loader />
</Center>
);
}
if (!invoice) {
return (
<Container size="sm" py="xl">
<Stack align="center" gap="md">
<Text fw={700}>Invoice not found</Text>
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/invoices")}
>
Back to invoices
</Button>
</Stack>
</Container>
);
}
return (
<PageContainer>
<PageHeader
breadcrumbs={[
{ label: "Invoices", href: "/dashboard/invoices" },
{ label: invoice.invoiceNumber },
]}
backTo="/dashboard/invoices"
title={invoice.invoiceNumber}
subtitle={`${humanize(invoice.source)} · ${invoice.sourceId}`}
meta={<InvoiceStatusBadge status={invoice.status} />}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Download invoice"
loading={downloading}
onClick={() => void downloadDocument()}
>
<Download size={16} />
</ActionIcon>
}
/>
<Stack gap="lg">
<Card>
<Stack gap="lg">
<Text fw={600} c="edr-text">
Summary
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="lg">
<InfoField label="Billed to" value={invoice.company?.name} />
<InfoField
label="Profile"
value={invoice.companyProfile?.reference}
/>
<InfoField label="Type" value={humanize(invoice.type)} />
<InfoField label="Currency" value={invoice.currency} />
<InfoField label="Issued" value={formatDate(invoice.issuedAt)} />
<InfoField label="Due" value={formatDate(invoice.dueAt)} />
<InfoField
label="Total"
value={formatMoney(invoice.totalAmount, invoice.currency)}
/>
<InfoField
label="Balance"
value={formatMoney(invoice.balanceAmount, invoice.currency)}
/>
</SimpleGrid>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Text fw={600} c="edr-text">
Line items
</Text>
<Table striped withRowBorders={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th>Charge type</Table.Th>
<Table.Th ta="right">Quantity</Table.Th>
<Table.Th ta="right">Unit rate</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(invoice.lines ?? []).map((line) => (
<Table.Tr key={line.id}>
<Table.Td>{line.description ?? line.chargeType}</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{humanize(line.chargeType)}
</Text>
</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">
{formatMoney(line.unitRate, line.currency)}
</Table.Td>
<Table.Td ta="right">
{formatMoney(line.amount, line.currency)}
</Table.Td>
</Table.Tr>
))}
{(invoice.lines ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="md">
No line items.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
<Group
justify="flex-end"
gap="xl"
pt="sm"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Subtotal
</Text>
<Text size="sm">
{formatMoney(invoice.subtotalAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Tax
</Text>
<Text size="sm">
{formatMoney(invoice.taxAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" c="edr-muted">
Paid
</Text>
<Text size="sm">
{formatMoney(invoice.paidAmount, invoice.currency)}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text size="xs" fw={700}>
Total
</Text>
<Text size="sm" fw={700}>
{formatMoney(invoice.totalAmount, invoice.currency)}
</Text>
</Stack>
</Group>
</Stack>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,237 @@
import type { Freight } from "@edr/types";
import {
ActionIcon,
Box,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { useQuery } from "@tanstack/react-query";
import { RefreshCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
InvoiceStatusBadge,
formatDate,
formatMoney,
humanize,
} from "@/components/customers";
import { PageContainer, PageHeader } from "@/components/page";
import { api } from "@/services/api";
import type { Invoice } from "@/types/invoice";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
export default function InvoicesPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
"",
);
const filter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
search: debouncedQuery,
status: statusFilter || undefined,
}),
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
);
const { data, isLoading, isError, refetch, isFetching } = useQuery(
api.invoices.list.queryOptions({ input: { filter } }),
);
const rows = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const columns: ColumnDef<Invoice>[] = useMemo(
() => [
{
id: "invoiceNumber",
header: "Invoice",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.invoiceNumber}
</Text>
),
},
{
id: "billedTo",
header: "Billed to",
cell: ({ row }) => (
<Text size="sm" c="edr-text">
{row.original.company?.name ?? "—"}
</Text>
),
},
{
id: "source",
header: "Source",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{humanize(row.original.source)}
</Text>
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
},
{
id: "amount",
header: "Amount",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{formatMoney(row.original.totalAmount, row.original.currency)}
</Text>
),
},
{
id: "balance",
header: "Balance",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatMoney(row.original.balanceAmount, row.original.currency)}
</Text>
),
},
{
id: "dueAt",
header: "Due",
meta: { headerClassName: "text-right", cellClassName: "text-right" },
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{formatDate(row.original.dueAt)}
</Text>
),
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="Invoices"
subtitle="Every invoice issued across bookings, warehouse fees and clearance charges."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
aria-label="Refresh"
loading={isFetching}
onClick={() => void refetch()}
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
size="sm"
radius="md"
value={statusFilter || "all"}
onChange={(v) => {
setStatusFilter(
v === "all" ? "" : (v as Freight.InvoiceStatus),
);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}}
data={[
{ label: "All", value: "all" },
{ label: "Pending", value: "PENDING" },
{ label: "Paid", value: "PAID" },
{ label: "Overdue", value: "OVERDUE" },
]}
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={920}>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
emptyMessage={
debouncedQuery
? "No invoices match your search."
: "No invoices yet."
}
error={
isError
? {
message: "Failed to load invoices.",
onRetry: () => void refetch(),
}
: undefined
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
</Box>
</Stack>
</Card>
</PageContainer>
);
}

View File

@@ -47,6 +47,14 @@ export interface FormFieldDef {
* showWhen and not match hideWhen.
*/
showWhen?: { field: string; equals: string[] };
/**
* Select options computed from other fields' current values. When set, the
* form resolves the option list at render time from the live form state
* instead of the static `options` list. Used for the rate unit selector,
* whose valid choices depend on `appliesTo` + `trigger`. (Named distinctly
* from the fleet config's string-based `dynamicOptions` to avoid a clash.)
*/
optionsFromValues?: (values: Record<string, unknown>) => { label: string; value: string }[];
}
export interface RuleEngineOrderConfig {
@@ -116,12 +124,54 @@ const RATE_TRIGGERS = [
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
];
const RATE_UNITS =["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "PER_INVOICE", "FLAT"].map(
(v) => ({
label: v.replace(/_/g, " "),
value: v,
}),
);
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
* Valid weighting units for a rate shape — mirrors the API's
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
*/
const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
if (appliesTo === "OTHER") {
switch (trigger) {
case "OVERWEIGHT":
return ["PER_TON"];
case "REEFER":
case "HAZARDOUS":
case "DEMURRAGE":
return ["PER_CONTAINER", "PER_TON"];
case "CANCELLATION":
return ["FLAT", "PER_INVOICE"];
case "CONSOLIDATION":
case "SHIPPING_LINE":
case "PIL_EXTRA_FEE":
return ["PER_CONTAINER", "FLAT"];
default:
return ["FLAT", "PER_TON", "PER_CONTAINER"];
}
}
switch (appliesTo) {
case "CONTAINER":
return ["PER_CONTAINER", "PER_WAGON"];
case "BULK":
return ["PER_TON", "PER_WAGON"];
case "INTERCITY":
return ["PER_CONTAINER", "PER_TON", "PER_WAGON", "PER_KM"];
case "FIRST_MILE":
case "LAST_MILE":
return ["PER_CONTAINER", "PER_TON", "PER_KM", "FLAT"];
default:
return ["FLAT"];
}
};
const rateUnitOptions = (values: Record<string, unknown>) => {
const appliesTo = String(values.appliesTo ?? "");
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
if (!appliesTo) return [];
return allowedRateUnits(appliesTo, trigger).map(unitOption);
};
const CURRENCIES = [
{ label: "USD", value: "USD" },
@@ -324,8 +374,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
{
@@ -343,8 +391,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS,
},
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
},
{
@@ -407,7 +453,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
],
formFields: [
{
@@ -457,9 +502,18 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
// is always per excess ton, so the unit field is hidden for it — the API
// forces PER_TON regardless.
{
name: "rateUnit",
label: "Rate unit",
type: "select",
required: true,
optionsFromValues: rateUnitOptions,
description: "Weighting basis — options depend on what the rate applies to.",
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
},
],
},
{

View File

@@ -41,6 +41,7 @@ import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowPhasePill,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
@@ -233,7 +234,16 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
</Text>
</Box>
</Group>
<WindowStatusPill status={schedule.bookingWindowStatus} />
<Stack gap={4} align="flex-end">
<WindowStatusPill status={schedule.bookingWindowStatus} />
{schedule.windowPhase ? (
<WindowPhasePill
phase={schedule.windowPhase}
cycleNo={schedule.bookingCycleNo}
size="sm"
/>
) : null}
</Stack>
</Group>
<RouteCorridor

View File

@@ -20,10 +20,12 @@ import {
import {
AlertTriangle,
ArrowLeft,
ArrowLeftRight,
Boxes,
CalendarDays,
CheckCircle2,
ChevronLeft,
ClipboardCheck,
ChevronRight,
Clock,
FileSignature,
@@ -52,6 +54,7 @@ import {
BookingPipeline,
HeroChip,
totalBookingCount,
WindowPhasePill,
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
@@ -62,6 +65,7 @@ import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardScheduleDetail,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
@@ -114,6 +118,61 @@ const fmtDateTime = (iso: string | null) =>
}).format(new Date(iso))
: "—";
const eatDayFmt = new Intl.DateTimeFormat("en-CA", {
timeZone: "Africa/Addis_Ababa",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/** "11:00 EAT" if the timestamp falls on today (EAT), else "05 Jun, 11:00 EAT". */
const fmtPhaseTime = (iso: string) => {
const date = new Date(iso);
const time = new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(date);
if (eatDayFmt.format(date) === eatDayFmt.format(new Date())) {
return `${time} EAT`;
}
const day = new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
timeZone: "Africa/Addis_Ababa",
}).format(date);
return `${day}, ${time} EAT`;
};
/** Countdown label for the current booking-cycle phase, e.g. "Closes 11:00 EAT". */
function phaseCountdown(data: BatchBoardScheduleDetail): string | null {
switch (data.windowPhase) {
case "PRE_WINDOW":
return data.windowOpensAt
? `Opens ${fmtPhaseTime(data.windowOpensAt)}`
: null;
case "OPEN":
return data.windowClosesAt
? `Closes ${fmtPhaseTime(data.windowClosesAt)}`
: null;
case "DOC_REVIEW":
return data.docReviewEndsAt
? `Doc review ends ${fmtPhaseTime(data.docReviewEndsAt)}`
: null;
case "PAYMENT":
return data.paymentPhaseEndsAt
? `Payment ends ${fmtPhaseTime(data.paymentPhaseEndsAt)}`
: null;
case "CLOSED_FOR_DAY":
return data.windowOpensAt
? `Reopens ${fmtPhaseTime(data.windowOpensAt)}`
: null;
default:
return null;
}
}
const initials = (name: string) =>
name
.split(/\s+/)
@@ -184,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
Gov
</Badge>
) : null}
{b.consolidationPartnerRef ? (
<Tooltip
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
withArrow
multiline
maw={260}
>
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ArrowLeftRight size={10} />}
style={{ textTransform: "none" }}
>
shared wagon · {b.consolidationPartnerRef}
</Badge>
</Tooltip>
) : null}
</Group>
);
},
@@ -462,6 +540,9 @@ export default function BatchScheduleDetailPage() {
const runAllocation = useMutation(
api.trainScheduling.runAllocation.mutationOptions(),
);
const completeDocReview = useMutation(
api.trainScheduling.completeDocReview.mutationOptions(),
);
const hasAssignedWagons = useMemo(
() =>
@@ -607,6 +688,24 @@ export default function BatchScheduleDetailPage() {
);
const selectedDay = dayGroups[selectedIndex];
const handleCompleteDocReview = () => {
completeDocReview
.mutateAsync(scheduleId ?? "")
.then(() => {
toast({
title: "Document review complete",
description: "Batch is running for this route-day group",
});
void refetch();
})
.catch(() => {
toast({
title: "Could not complete document review",
variant: "destructive",
});
});
};
const handleRunAllocation = () => {
runAllocation
.mutateAsync({ scheduleId: scheduleId ?? "" })
@@ -641,6 +740,7 @@ export default function BatchScheduleDetailPage() {
}
const totalBookings = totalBookingCount(data.counts);
const countdown = phaseCountdown(data);
return (
<PageContainer fluid>
@@ -689,6 +789,12 @@ export default function BatchScheduleDetailPage() {
{data.trainNumber ?? data.routeName ?? "Schedule"}
</Title>
<WindowStatusPill status={data.bookingWindowStatus} />
{data.windowPhase ? (
<WindowPhasePill
phase={data.windowPhase}
cycleNo={data.bookingCycleNo}
/>
) : null}
<HeroChip>{data.status}</HeroChip>
</Group>
<RouteCorridor
@@ -717,6 +823,12 @@ export default function BatchScheduleDetailPage() {
{data.locomotive.maxTrainLengthMeters} m
</HeroChip>
) : null}
{data.windowPhase ? (
<HeroChip icon={<Clock size={12} />}>
Cycle {data.bookingCycleNo}
{countdown ? ` · ${countdown}` : ""}
</HeroChip>
) : null}
</Group>
</Stack>
@@ -730,6 +842,17 @@ export default function BatchScheduleDetailPage() {
>
Refresh
</Button>
{data.windowPhase === "DOC_REVIEW" ? (
<Button
color="yellow"
radius="md"
leftSection={<ClipboardCheck size={16} />}
loading={completeDocReview.isPending}
onClick={handleCompleteDocReview}
>
Doc review complete run batch
</Button>
) : null}
<Button
color="edr-green"
radius="md"

View File

@@ -42,6 +42,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
@@ -141,6 +142,13 @@ export default function TrainScheduleV2DetailPage() {
},
});
const importLoadingQuery = useQuery(
api.trainScheduling.importLoadingBookings.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
}),
);
const eligibleFilters = useMemo(
() =>
schedule
@@ -951,6 +959,23 @@ export default function TrainScheduleV2DetailPage() {
]}
/>
{schedule?.direction === "IMPORT" ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Import loading confirmation</Text>
<Text size="sm" c="dimmed">
Paid import bookings with a wagon allocated on this schedule. Marking loaded/unloaded
is tracking only it does not block dispatch.
</Text>
<ImportLoadingConfirmationPanel
scheduleId={scheduleId as string}
items={importLoadingQuery.data?.items ?? []}
isLoading={importLoadingQuery.isLoading}
/>
</Stack>
</Paper>
) : null}
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">

View File

@@ -36,6 +36,10 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
locomotiveOption,
showScheduleWarnings,
} from "@/components/trainScheduling/locomotiveOptions";
import {
RouteCorridor,
StatusPill,
@@ -110,7 +114,7 @@ export default function TrainScheduleV2ListPage() {
selectedRoute.originYard?.label ??
selectedRoute.originYard?.code ??
"the route origin yard";
return `Only locomotives currently at ${originLabel} are shown`;
return `All in-service locomotives are shown — those not yet at ${originLabel} or already on future schedules are flagged`;
}, [selectedRoute]);
useEffect(() => {
@@ -356,6 +360,7 @@ export default function TrainScheduleV2ListPage() {
payload: { routeId, scheduleDate, locomotiveIds },
});
toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings);
setCreateOpen(false);
navigate(`/dashboard/operations/train-scheduling-v2/${created.id}`);
} catch (err) {
@@ -554,10 +559,7 @@ export default function TrainScheduleV2ListPage() {
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? `${l.name}` : ""}`,
}))}
data={(locomotivesQuery.data ?? []).map((l) => locomotiveOption(l))}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable

View File

@@ -10,7 +10,10 @@ export default function TrainSchedulingGlobalRulesPage() {
const { toast } = useToast();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<Partial<TrainSchedulingGlobalRules>>({});
// Fields hold raw NumberInput values (number | string) while editing; coerced to Number on save.
const [form, setForm] = useState<
Partial<Record<keyof TrainSchedulingGlobalRules, number | string>>
>({});
useEffect(() => {
void (async () => {
@@ -34,6 +37,13 @@ export default function TrainSchedulingGlobalRulesPage() {
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
importWindowLeadDays: Number(form.importWindowLeadDays),
exportBookingLeadHours: Number(form.exportBookingLeadHours),
windowOpenHour: Number(form.windowOpenHour),
windowDurationHours: Number(form.windowDurationHours),
docReviewMinutes: Number(form.docReviewMinutes),
paymentWindowMinutes: Number(form.paymentWindowMinutes),
reopenDelayMinutes: Number(form.reopenDelayMinutes),
});
setForm(updated);
toast({ title: "Train scheduling rules saved" });
@@ -58,7 +68,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: Number(value) }))
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
min={1}
disabled={loading}
@@ -68,7 +78,7 @@ export default function TrainSchedulingGlobalRulesPage() {
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: Number(value) }))
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
min={1}
disabled={loading}
@@ -77,7 +87,7 @@ export default function TrainSchedulingGlobalRulesPage() {
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxWagonsPerTrain: Number(value) }))
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
}
min={1}
disabled={loading}
@@ -89,7 +99,7 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: Number(value),
max20ftContainerWeightTons: value,
}))
}
min={0.001}
@@ -102,12 +112,93 @@ export default function TrainSchedulingGlobalRulesPage() {
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: Number(value),
max20ftPairWeightDiffTons: value,
}))
}
min={0}
disabled={loading}
/>
</Stack>
</Card>
<Card maw={720} mt="md">
<Stack gap="md">
<PageHeader
title="Booking windows"
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
/>
<NumberInput
label="Import window lead (days)"
description="The single booking day opens this many days before departure"
value={form.importWindowLeadDays ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, importWindowLeadDays: value }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Export booking lead (hours)"
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
value={form.exportBookingLeadHours ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Window open hour (EAT)"
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
value={form.windowOpenHour ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowOpenHour: value }))
}
min={0}
max={23}
disabled={loading}
/>
<NumberInput
label="Window duration (hours)"
value={form.windowDurationHours ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, windowDurationHours: value }))
}
min={0.25}
max={12}
step={0.25}
disabled={loading}
/>
<NumberInput
label="Document review (minutes)"
description="Max staff time to accept booking documents after the window closes"
value={form.docReviewMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, docReviewMinutes: value }))
}
min={0}
disabled={loading}
/>
<NumberInput
label="Payment window (minutes)"
description="Time a selected customer has to pay before the slot expires"
value={form.paymentWindowMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
}
min={1}
disabled={loading}
/>
<NumberInput
label="Reopen delay (minutes)"
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
value={form.reopenDelayMinutes ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
}
min={1}
disabled={loading}
/>
<Group justify="flex-end">
<Button loading={saving} disabled={loading} onClick={() => void handleSave()}>
Save rules

View File

@@ -14,7 +14,17 @@ import {
Text,
} from '@mantine/core';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronRight, Eye, FileText, History, PackageOpen, Truck } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
ChevronDown,
ChevronRight,
Eye,
FileText,
History,
PackageOpen,
ShieldCheck,
Truck,
} from 'lucide-react';
import { PageHeader } from '@/components/page';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
@@ -36,6 +46,7 @@ import {
useInterchangeDocuments,
} from '@/hooks/useInterchangeDocuments';
import { useToast } from '@/hooks/use-toast';
import { trainSchedulingService } from '@/services/trainScheduling.service';
import type {
AutoUnloadExportDjiboutiResult,
ExportTrain,
@@ -179,6 +190,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
const autoUnload = useAutoUnloadExportAtDjibouti();
const generateInterchange = useGenerateInterchangeDocument();
const qc = useQueryClient();
const secureGatePass = useMutation({
mutationFn: (scheduleId: string) => trainSchedulingService.grantImportDjiboutiGatepass(scheduleId),
onSuccess: () =>
qc.invalidateQueries({
queryKey: ['warehouse-inventory', 'export-djibouti-arrival-queue'],
}),
});
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
const [historyInventoryId, setHistoryInventoryId] = useState<string | null>(null);
@@ -189,6 +208,25 @@ export default function ExportDjiboutiUnloadingQueuePage() {
.map((doc) => [doc.scheduleId as string, doc]),
);
const secureGate = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
await secureGatePass.mutateAsync(train.scheduleId);
toast({
title: 'Gate pass secured',
description: `Djibouti Port entry allowed for ${train.trainNumber ?? 'the train'}. You can now auto unload.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Could not secure gate pass',
description: getErrorMessage(error),
});
} finally {
setBusyScheduleId(null);
}
};
const unloadTrain = async (train: ExportTrain) => {
setBusyScheduleId(train.scheduleId);
try {
@@ -346,6 +384,16 @@ export default function ExportDjiboutiUnloadingQueuePage() {
>
Open
</Button>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<ShieldCheck size={14} />}
loading={busyScheduleId === train.scheduleId && secureGatePass.isPending}
onClick={() => secureGate(train)}
>
Secure Gate Pass
</Button>
<Button
size="compact-xs"
color="green"
@@ -356,7 +404,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
<Truck size={14} />
)
}
loading={busyScheduleId === train.scheduleId}
loading={busyScheduleId === train.scheduleId && autoUnload.isPending}
onClick={() => unloadTrain(train)}
>
Auto Unload Export Items

View File

@@ -21,6 +21,7 @@ export default function WarehouseInventoryPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
const direction = (searchParams.get('direction') as 'IMPORT' | 'EXPORT' | null) ?? undefined;
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
@@ -28,8 +29,8 @@ export default function WarehouseInventoryPage() {
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, search: debouncedSearch || undefined }),
[filter, debouncedSearch],
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
);
const warehousesQuery = useWarehouses();
@@ -53,7 +54,13 @@ export default function WarehouseInventoryPage() {
return (
<PageContainer>
<PageHeader
title="Warehouse Inventory"
title={
direction === 'IMPORT'
? 'Import Terminal Inventory'
: direction === 'EXPORT'
? 'Export Terminal Inventory'
: 'Warehouse Inventory'
}
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
action={
<Group gap="xs">