Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-09 01:44:26 +00:00
313 changed files with 19072 additions and 4277 deletions

View File

@@ -16,6 +16,10 @@ import {
setCookie,
} from "./cookies";
import { applyTokens } from "./http";
import {
startTokenRefreshScheduler,
stopTokenRefreshScheduler,
} from "./refreshScheduler";
import type { AuthTokens, AuthUser } from "./types";
interface LoginPayload {
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
void bootstrap();
}, []);
// Keep the server session alive while a user is logged in. Runs after
// login, MFA verification, and page-reload bootstrap alike.
useEffect(() => {
if (!user) {
stopTokenRefreshScheduler();
return;
}
startTokenRefreshScheduler();
return stopTokenRefreshScheduler;
}, [user]);
const value = useMemo<AuthContextValue>(
() => ({
user,

View File

@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
};
/**
* Single-flight token refresh: concurrent callers (the 401 interceptor and
* the proactive scheduler) share one in-flight request so the refresh token
* is only rotated once. Throws if no refresh token is stored or the server
* rejects it — callers decide how to end the session.
*/
const refreshSessionTokens = async (): Promise<AuthTokens> => {
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
throw new Error("missing refresh token");
}
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
return tokens;
};
api.interceptors.request.use((config) => {
const token = getCookie(AUTH_TOKEN_COOKIE);
@@ -65,8 +89,7 @@ api.interceptors.response.use(
return Promise.reject(error);
}
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
if (!refreshToken) {
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
clearSessionCookies();
return Promise.reject(error);
}
@@ -74,15 +97,7 @@ api.interceptors.response.use(
originalRequest._retry = true;
try {
refreshPromise ??= api
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
.then((response) => response.data)
.finally(() => {
refreshPromise = null;
});
const tokens = await refreshPromise;
applyTokens(tokens);
const tokens = await refreshSessionTokens();
originalRequest.headers = {
...originalRequest.headers,
Authorization: `Bearer ${tokens.token}`,
@@ -97,4 +112,4 @@ api.interceptors.response.use(
},
);
export { api, applyTokens };
export { api, applyTokens, refreshSessionTokens };

View File

@@ -0,0 +1,82 @@
import { isAxiosError } from "axios";
import {
REFRESH_TOKEN_COOKIE,
clearSessionCookies,
getCookie,
} from "./cookies";
import { refreshSessionTokens } from "./http";
/**
* Proactively refreshes the token pair on a fixed cadence so the server-side
* session (a sliding 1-hour window, extended only by /auth/refresh-token) is
* kept alive while the app is open. The 401 interceptor in http.ts remains
* the reactive fallback; both share the same single-flight refresh call.
*
* The interval MUST stay well under the server session window (60 min).
*/
const DEFAULT_INTERVAL_MINUTES = 10;
const getIntervalMs = () => {
const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
return (
(Number.isFinite(minutes) && minutes > 0
? minutes
: DEFAULT_INTERVAL_MINUTES) * 60_000
);
};
let timerId: number | null = null;
let lastRefreshAt = 0;
const refreshNow = async () => {
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
// Logged out elsewhere; nothing to keep alive.
stopTokenRefreshScheduler();
return;
}
try {
await refreshSessionTokens();
lastRefreshAt = Date.now();
} catch (error) {
// Network hiccups are retried on the next tick; only an explicit server
// rejection means the session is dead.
if (isAxiosError(error) && error.response) {
stopTokenRefreshScheduler();
clearSessionCookies();
window.location.replace("/auth");
}
}
};
/**
* Browsers freeze timers in background tabs — a tab waking up past its
* refresh deadline refreshes immediately instead of waiting a full interval.
*/
const onVisibilityChange = () => {
if (document.visibilityState !== "visible") return;
if (Date.now() - lastRefreshAt >= getIntervalMs()) {
void refreshNow();
}
};
export const startTokenRefreshScheduler = () => {
stopTokenRefreshScheduler();
// Token age is unknown here (fresh login vs. hours-old page reload), so
// refresh right away to extend the session window from "now".
lastRefreshAt = 0;
void refreshNow();
timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
document.addEventListener("visibilitychange", onVisibilityChange);
};
export const stopTokenRefreshScheduler = () => {
if (timerId !== null) {
window.clearInterval(timerId);
timerId = null;
}
document.removeEventListener("visibilitychange", onVisibilityChange);
};

View File

@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
EXPIRED: "red",
PAID: "edr-green",
IN_TRANSIT: "cyan",
ARRIVED: "teal",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",

View File

@@ -0,0 +1,146 @@
import { useMemo } from "react";
import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core";
import { FileText, FolderOpen } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { downloadBookingFile } from "@/services/files.service";
import { useFileViewer } from "@/hooks/useFileViewer";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
import { SectionCard } from "./SectionCard";
interface LabeledFile {
label: string;
file: { id: string; name: string };
}
/**
* Every document tied to a booking, in one tab: the customer/GL clearance
* documents, the customs workflow files (declaration/duty/transit/Djibouti),
* the duty-tax notice, and the final invoice + payment slip. All fetched from
* the booking's clearance view (the only endpoint that surfaces booking files),
* each with inline view + download.
*/
export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) {
const { view, viewer } = useFileViewer();
const { data: clearance, isLoading, isError } = useQuery({
queryKey: ["clearance", bookingId],
queryFn: () => bookingsService.getClearance(bookingId),
});
const onDownload = (f: { id: string; name: string }) =>
void downloadBookingFile(f.id, f.name);
// Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots).
const clearanceDocs = useMemo<
Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }>
>(
() =>
(clearance?.documents ?? [])
.filter((d) => d.file)
.map((d) => ({ doc: d, file: d.file! })),
[clearance],
);
const workflowFiles = useMemo(
() => (clearance?.workflowFiles ?? []).filter((f) => f.file),
[clearance],
);
// Duty notice + final invoice + payment slip — loose files that don't ride in
// the documents/workflow arrays.
const otherFiles = useMemo<LabeledFile[]>(() => {
const rows: LabeledFile[] = [];
const notice = clearance?.dutyAdvice?.noticeFile;
if (notice) rows.push({ label: "Duty & tax notice", file: notice });
const inv = clearance?.finalInvoice;
if (inv?.invoiceFile)
rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile });
if (inv?.slipFile)
rows.push({ label: "Final invoice payment slip", file: inv.slipFile });
return rows;
}, [clearance]);
if (isLoading) {
return (
<Center py={60}>
<Group gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading documents</Text>
</Group>
</Center>
);
}
const hasAny =
clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0;
if (isError || !hasAny) {
return (
<SectionCard icon={FolderOpen} title="Documents" accent="edr-green">
<Center py={28}>
<Stack align="center" gap={6}>
<FolderOpen size={26} color="var(--mantine-color-gray-5)" />
<Text fw={600}>No documents yet</Text>
<Text size="sm" c="dimmed" ta="center" maw={360}>
{isError
? "Couldnt load this bookings documents."
: "Documents attached to this booking will appear here as theyre uploaded."}
</Text>
</Stack>
</Center>
</SectionCard>
);
}
return (
<Stack gap="lg">
{clearanceDocs.length > 0 && (
<SectionCard icon={FileText} title="Clearance documents" accent="edr-green">
<Stack gap={8}>
{clearanceDocs.map(({ doc, file }) => (
<PhasedUploadedFileRow
key={doc.fileKey}
label={`${doc.label}${doc.uploadedBy === "gl" ? " · GL" : ""}`}
file={file}
onView={view}
onDownload={onDownload}
compact
/>
))}
</Stack>
</SectionCard>
)}
{workflowFiles.length > 0 && (
<ClearanceWorkflowFilesPanel
files={workflowFiles}
onView={view}
onDownload={onDownload}
/>
)}
{otherFiles.length > 0 && (
<SectionCard icon={FileText} title="Invoices & notices" accent="edr-green">
<Stack gap={8}>
{otherFiles.map((row) => (
<PhasedUploadedFileRow
key={row.file.id}
label={row.label}
file={row.file}
onView={view}
onDownload={onDownload}
compact
/>
))}
</Stack>
</SectionCard>
)}
<Box>{viewer}</Box>
</Stack>
);
}

View File

@@ -1,6 +1,7 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -630,6 +630,20 @@ function DocReviewCard({
</Button>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Button
component="a"
href={fileViewUrl(doc.file!.id, true)}
size="compact-xs"
variant="default"
radius="md"
leftSection={<Download size={13} />}
>
Download
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -646,7 +646,9 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID";
if (!clearance.offloaded && !invoice) {
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
return (
<StepStatus
done={false}
@@ -740,7 +742,7 @@ function FinalInvoiceStep({
) : canDjAct && bookingId ? (
<>
<Text size="sm" c="dimmed">
Cargo offloaded send the final invoice to the customer.
Send the final invoice to the customer if post-arrival charges apply (optional).
</Text>
<Button
color="edr-green"

View File

@@ -150,16 +150,19 @@ export default function GlCreateBookingForm() {
[bookingWindows],
);
// Soonest future window across all routes, used for the "next window" notice.
// Next future window across all routes, used for the "next window" notice
// the train dispatching soonest among those not yet open, matching the
// departure-date ordering of the window cards.
const nextWindow = useMemo(() => {
const now = Date.now();
return (bookingWindows ?? [])
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
.sort(
(a, b) =>
new Date(a.windowOpensAt!).getTime() -
new Date(b.windowOpensAt!).getTime(),
)[0];
.sort((a, b) => {
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
})[0];
}, [bookingWindows]);
const [scheduledDate, setScheduledDate] = useState("");

View File

@@ -21,7 +21,29 @@ import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling";
/**
* The fields a window card needs. Structural so both `StaffBookingWindow`
* (all-lanes staff feed) and `BookingWindow` (contract-scoped feed, which
* carries no train number) satisfy it.
*/
interface WindowRow {
scheduleId: string;
reference?: string | null;
trainNumber?: string | null;
direction: string | null;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
@@ -46,7 +68,7 @@ function fmtTime(iso: string): string {
});
}
function windowLabel(w: StaffBookingWindow): string {
function windowLabel(w: WindowRow): string {
if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt,
@@ -64,7 +86,7 @@ function windowLabel(w: StaffBookingWindow): string {
* between refetches announces what comes next rather than the bare "Expired".
*/
function phaseCountdown(
w: StaffBookingWindow,
w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
@@ -104,19 +126,18 @@ function phaseCountdown(
}
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: StaffBookingWindow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
/**
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
* client clock. The server query already excludes terminal / departed rows;
* comparing `Date.now()` here only re-introduced clock skew that made a card
* vanish and reappear on refresh. Trust the server phase (live-patched over the
* socket) instead.
*/
function isPast(w: WindowRow): boolean {
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
}
function WindowCard({ w }: { w: StaffBookingWindow }) {
function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const isImport = w.direction === "IMPORT";
@@ -175,6 +196,11 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
{w.destination ?? "—"}
</Text>
</Group>
{w.reference ? (
<Text fz={12} fw={600} ff="monospace" c="edr-green.7" truncate>
{w.reference}
</Text>
) : null}
{w.trainNumber ? (
<Text fz={12} c="dimmed" truncate>
Train {w.trainNumber}
@@ -218,21 +244,45 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
);
}
interface GlUpcomingWindowsSectionProps {
/**
* Scope the card to one contract: only windows on that contract's routes
* (and therefore its import/export direction) are shown. Omit for the
* all-lanes staff feed on the clearance queue.
*/
contractId?: string;
}
/**
* All announced booking windows (import cycles + export FCFS) across every lane,
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
* Hidden when nothing is pending.
* Announced booking windows (import cycles + export FCFS) as a paged carousel —
* three lanes per page, arrows to flip. Without `contractId` it shows every
* lane (GL ET clearance queue); with `contractId` it shows only the windows
* matching that contract's routes/direction (clearance detail page). Mirrors
* the customer's portal "Booking Windows" card. Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection() {
export function GlUpcomingWindowsSection({
contractId,
}: GlUpcomingWindowsSectionProps = {}) {
// Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback.
useBookingWindowSocket();
const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({
const allLanes = useQuery({
...api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000,
}),
);
enabled: !contractId,
});
const contractLanes = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
refetchInterval: 60_000,
}),
enabled: Boolean(contractId),
});
const data: WindowRow[] | undefined = contractId
? contractLanes.data
: allLanes.data;
const isLoading = contractId ? contractLanes.isLoading : allLanes.isLoading;
const [page, setPage] = useState(0);
const windows = useMemo(() => {
@@ -241,13 +291,13 @@ export function GlUpcomingWindowsSection() {
);
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
// guard above already excludes them; they never reach the upcoming list.
// Open lanes first, then by opening time.
// Order by the train's dispatch (departure) date, nearest first. Open-now
// breaks ties on the same departure.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
if (da !== db) return da - db;
return Number(b.isOpenNow) - Number(a.isOpenNow);
});
}, [data]);
@@ -270,7 +320,9 @@ export function GlUpcomingWindowsSection() {
Booking windows
</Text>
<Text fz={13} c="dimmed">
Import and export booking windows across all lanes (EAT)
{contractId
? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"}
</Text>
</Box>
</Group>

View File

@@ -727,9 +727,9 @@ function ImportT1UploadStep({
);
}
const departed = Boolean(t1.trainDepartedAt);
const canUpload =
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
// closes/accepts the T1.
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
return (
<Stack gap="sm">
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
</Alert>
) : uploaded.length === 0 && !canUpload ? (
<StepStatus
done={false}

View File

@@ -0,0 +1,379 @@
import {
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Group,
Modal,
SimpleGrid,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useQuery, useMutation } from "@tanstack/react-query";
import {
AlertTriangle,
ClipboardCheck,
Clock,
FilePlus2,
FileX2,
} from "lucide-react";
import { useState } from "react";
import { useFileViewer } from "@edr/ui-common";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import type { Company, CompanyChangeRequest } from "@/types/customer";
import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
const FIELD_LABELS: Record<string, string> = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
companyLocation: "Location",
companyAddress: "Address",
tin: "TIN",
vatNumber: "VAT number",
fanNumber: "FAN number",
nationality: "Nationality",
licenceNumber: "Licence number",
contactPersonName: "Contact person",
contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone",
generalManagerName: "General manager",
generalManagerEmail: "GM email",
generalManagerPhone: "GM phone",
poaName: "PoA name",
poaPhone: "PoA phone",
poaEmail: "PoA email",
poaLocation: "PoA location",
poaAddress: "PoA address",
region: "Region",
zone: "Zone",
woreda: "Woreda",
kebele: "Kebele",
houseNo: "House no.",
};
/** Best-effort current value on the live company for a proposed field key. */
function currentValue(company: Company, key: string): string {
const c = company as unknown as Record<string, unknown>;
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
const map: Record<string, unknown> = {
companyName: c.name,
companyEmail: c.email,
companyPhone: c.phone,
companyLocation: c.country,
companyAddress: c.address,
tin: c.tin,
vatNumber: c.vatNumber,
fanNumber: c.fanNumber,
nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
};
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v);
}
function DiffRow({
label,
from,
to,
}: {
label: string;
from: string;
to: string;
}) {
const changed = from !== to;
return (
<Stack gap={2}>
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
{label}
</Text>
<Group gap={8} wrap="nowrap" align="center">
<Text
size="sm"
c="dimmed"
td={changed ? "line-through" : undefined}
style={{ wordBreak: "break-word" }}
>
{from}
</Text>
{changed && (
<>
<Text size="sm" c="edr-muted">
</Text>
<Text size="sm" fw={600} c="edr-text">
{to}
</Text>
</>
)}
</Group>
</Stack>
);
}
/**
* Backoffice review surface for a customer's staged profile edits. Shows the
* pending change request as a proposed-vs-current diff with Approve / Reject
* (with note) actions, plus a short history of past decisions.
*/
export function ChangeRequestReview({ company }: { company: Company }) {
const query = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
);
const approve = useMutation(
api.customers.approveChangeRequest.mutationOptions(),
);
const reject = useMutation(
api.customers.rejectChangeRequest.mutationOptions(),
);
const { view, viewer } = useFileViewer();
const [rejectId, setRejectId] = useState<string | null>(null);
const [note, setNote] = useState("");
const requests = query.data ?? [];
const pending = requests.find((r) => r.status === "pending");
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
if (!pending && history.length === 0) return null;
const proposedKeys = pending
? Object.keys(pending.snapshot ?? {})
: ([] as string[]);
const docCount = pending?.documentFileIds?.length ?? 0;
const licenseChanges = pending?.licenseChanges ?? [];
const confirmReject = () => {
if (!rejectId) return;
reject.mutate(
{ id: rejectId, note: note.trim() },
{
onSuccess: () => {
setRejectId(null);
setNote("");
},
},
);
};
return (
<>
{pending && (
<Card withBorder>
<Stack gap="md">
<Group justify="space-between">
<Group gap="sm">
<ClipboardCheck size={18} className="text-edr-muted" />
<Text fw={600} c="edr-text">
Profile changes awaiting review
</Text>
<Badge color="yellow" variant="light" radius="md">
Pending
</Badge>
</Group>
<Text size="xs" c="dimmed">
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
</Text>
</Group>
{proposedKeys.length > 0 ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
{proposedKeys.map((key) => (
<DiffRow
key={key}
label={FIELD_LABELS[key] ?? humanize(key)}
from={currentValue(company, key)}
to={
pending.snapshot[key] === null ||
pending.snapshot[key] === undefined ||
pending.snapshot[key] === ""
? "—"
: String(pending.snapshot[key])
}
/>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No field changes document uploads only.
</Text>
)}
{docCount > 0 && (
<Text size="sm" c="dimmed">
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
request review them in the Documents tab.
</Text>
)}
{licenseChanges.length > 0 && (
<Stack gap={8}>
<Text size="sm" fw={600} c="edr-text">
Business license changes
</Text>
{licenseChanges.map((c, i) => (
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
{c.op === "add" ? (
<FilePlus2 size={15} className="text-edr-muted" />
) : (
<FileX2 size={15} className="text-edr-muted" />
)}
<Badge
size="sm"
radius="sm"
variant="light"
color={c.op === "add" ? "green" : "red"}
>
{c.op === "add" ? "Add" : "Remove"}
</Badge>
<Anchor
component="button"
type="button"
size="sm"
onClick={() =>
view({
name: c.fileName ?? "License document",
url: fileViewUrl(c.fileId),
})
}
style={{
textDecoration:
c.op === "remove" ? "line-through" : undefined,
}}
>
{c.fileName ?? "License document"}
</Anchor>
</Group>
))}
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="red"
onClick={() => {
setRejectId(pending.id);
setNote("");
}}
>
Reject
</Button>
<Button
color="edr-green"
loading={approve.isPending}
onClick={() => approve.mutate({ id: pending.id })}
>
Approve changes
</Button>
</Group>
</Stack>
</Card>
)}
{history.length > 0 && (
<Card withBorder>
<Stack gap="sm">
<Text fw={600} c="edr-text">
Review history
</Text>
{history.map((r: CompanyChangeRequest) => (
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
<Badge
color={r.status === "approved" ? "edr-green" : "red"}
variant="light"
radius="md"
tt="capitalize"
>
{r.status}
</Badge>
<Box style={{ flex: 1 }}>
<Text size="sm" c="edr-text">
{formatDate(r.reviewedAt ?? r.updatedAt)}
</Text>
{r.note && (
<Text size="xs" c="dimmed">
Note: {r.note}
</Text>
)}
</Box>
</Group>
))}
</Stack>
</Card>
)}
<Modal
opened={rejectId !== null}
onClose={() => setRejectId(null)}
title="Reject changes"
centered
radius="lg"
>
<Stack gap="md">
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
The customer will see this note and can amend and resubmit.
</Alert>
<Textarea
label="Reason for rejection"
placeholder="e.g. The company address doesn't match the trade license."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectId(null)}
disabled={reject.isPending}
>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
>
Reject changes
</Button>
</Group>
</Stack>
</Modal>
{viewer}
</>
);
}
/** Compact "N changes pending" pill for the customer list/detail header. */
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
const query = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
);
const pending = (query.data ?? []).some((r) => r.status === "pending");
if (!pending) return null;
return (
<Badge
color="yellow"
variant="light"
radius="md"
leftSection={<Clock size={12} />}
>
Changes pending review
</Badge>
);
}

View File

@@ -1,6 +1,16 @@
import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import {
Badge,
Button,
Group,
Modal,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/services/api";
import type {
@@ -25,6 +35,7 @@ const badgeStyle = {
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
active: "edr-green",
pending: "yellow",
rejected: "red",
suspended: "orange",
blacklisted: "red",
};
@@ -177,6 +188,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
APPROVED: "cyan",
PAID: "edr-green",
IN_TRANSIT: "blue",
ARRIVED: "teal",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",
@@ -265,7 +277,9 @@ export function InvoiceStatusBadge({
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
* Rejecting captures a note the customer sees so they can fix and reapply.
*/
export function ProfileApprovalActions({
profileId,
@@ -277,33 +291,102 @@ export function ProfileApprovalActions({
const { mutate, isPending } = useMutation(
api.customers.setProfileStatus.mutationOptions(),
);
const [rejectOpen, setRejectOpen] = useState(false);
const [note, setNote] = useState("");
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
const confirmReject = () => {
mutate(
{ profileId, status: "rejected", note: note.trim() },
{ onSuccess: () => setRejectOpen(false) },
);
};
const rejectModal = (
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject profile"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Tell the customer what needs fixing. They'll see this note and can
amend and resubmit the role for approval.
</Text>
<Textarea
label="Reason for rejection"
placeholder="e.g. The uploaded business license is expired."
autosize
minRows={3}
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
required
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
onClick={() => setRejectOpen(false)}
disabled={isPending}
>
Cancel
</Button>
<Button
color="red"
loading={isPending}
disabled={note.trim().length === 0}
onClick={confirmReject}
>
Reject profile
</Button>
</Group>
</Stack>
</Modal>
);
if (status === "pending") {
return (
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
loading={isPending}
onClick={() => act("blacklisted")}
>
Reject
</Button>
</Group>
<>
{rejectModal}
<Group gap={6} wrap="nowrap">
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
<Button
size="xs"
variant="light"
color="red"
radius="md"
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
</Group>
</>
);
}
if (status === "rejected") {
return (
<Button
size="xs"
variant="light"
color="edr-green"
radius="md"
loading={isPending}
onClick={() => act("active")}
>
Approve
</Button>
);
}

View File

@@ -9,5 +9,9 @@ export {
ProfileStatusBadge,
ProfileTypeBadge,
} from "./badges";
export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
export { TableCard, type TableCardProps } from "./TableCard";

View File

@@ -33,7 +33,9 @@ const FleetRecordActions = ({
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
(config.slug === "drivers" ||
config.slug === "vehicles" ||
config.slug === "wagons");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;

View File

@@ -0,0 +1,133 @@
import type { ReactNode } from "react";
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
import { api } from "@/services/api";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import type { WagonMovementRecord } from "@/services/wagon.service";
export interface WagonMovementHistoryModalProps {
opened: boolean;
onClose: () => void;
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
/** Chip style per wagon_movements ledger kind. */
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
LOADED: {
label: "Loaded leg",
color: "edr-green",
icon: <PackageCheck size={14} />,
},
EMPTY_REPOSITION: {
label: "Empty reposition",
color: "blue",
icon: <TrainFront size={14} />,
},
MANUAL: {
label: "Manual move",
color: "orange",
icon: <Wrench size={14} />,
},
};
const yardLabel = (
yard: { label?: string; code?: string } | null | undefined,
yardId: string | null,
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
/**
* Movement ledger for one wagon: every relocation between yards — booking legs,
* empty reposition rides, and manual staff corrections — newest first.
*/
const WagonMovementHistoryModal = ({
opened,
onClose,
record,
}: WagonMovementHistoryModalProps) => {
const r = asObj(record);
const id = r.id ? String(r.id) : "";
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
const { data, isLoading } = useQuery(
api.wagons.movements.queryOptions({
input: { id },
enabled: opened && Boolean(id),
}),
);
const movements: WagonMovementRecord[] = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : movements.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No movements recorded yet. Every yard-to-yard move appears here a
booking's loaded leg, an empty reposition ride, or a manual correction.
</Text>
) : (
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
{movements.map((movement) => {
const meta = KIND_META[movement.kind] ?? {
label: movement.kind,
color: "gray",
icon: <TrainFront size={14} />,
};
const from = yardLabel(movement.fromYard, movement.fromYardId);
const to = yardLabel(movement.toYard, movement.toYardId);
return (
<Timeline.Item
key={movement.id}
bullet={meta.icon}
title={
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{from}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
</Group>
}
>
{movement.note && (
<Text size="sm" c="dimmed">
{movement.note}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(movement.occurredAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default WagonMovementHistoryModal;

View File

@@ -154,7 +154,37 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
Still accruing no delivery/return time yet. The amount grows until the vehicle is returned.
</Text>
)}
{preview.tiers && preview.tiers.length > 0 ? (
{preview.groups && preview.groups.length > 1 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Truck type</Table.Th>
<Table.Th>Trucks</Table.Th>
<Table.Th>Days</Table.Th>
<Table.Th ta="right">Rate / truck / day</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{preview.groups.map((g, i) => (
<Table.Tr key={i}>
<Table.Td>
{g.vehicleType ?? 'Unknown'}
{!g.ruleId && (
<Text span size="xs" c="red">
{' '}· no rule
</Text>
)}
</Table.Td>
<Table.Td>{g.truckCount}</Table.Td>
<Table.Td>{g.chargeableDays}</Table.Td>
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : preview.tiers && preview.tiers.length > 0 ? (
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
<Table.Thead>
<Table.Tr>

View File

@@ -0,0 +1,404 @@
import { useMemo } from "react";
import {
Alert,
Badge,
Box,
Group,
Paper,
Progress,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Crown,
Container,
Boxes,
FlaskConical,
Layers,
Ruler,
Scale,
Sparkles,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
import {
simulateBatch,
limitsFromDetail,
type BlockingAxis,
type ForecastRow,
} from "./batchForecast";
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
};
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtMeters = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
const AXIS_LABEL: Record<BlockingAxis, string> = {
wagons: "wagon slots full",
weight: "over max pull weight",
length: "over train length",
};
/** One capacity axis as a labelled meter (used vs cap). */
function AxisMeter({
icon: Icon,
label,
used,
cap,
fmt,
color,
}: {
icon: typeof Scale;
label: string;
used: number;
cap: number | null;
fmt: (n: number) => string;
color: string;
}) {
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
const near = pct >= 90;
return (
<Box style={{ flex: 1, minWidth: 150 }}>
<Group justify="space-between" mb={4} wrap="nowrap">
<Group gap={5} wrap="nowrap">
<Icon size={13} color={cardVar(color, 6)} />
<Text size="xs" c="dimmed" fw={600}>
{label}
</Text>
</Group>
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
{fmt(used)}
{cap != null ? ` / ${fmt(cap)}` : ""}
</Text>
</Group>
<Progress
value={pct}
size="md"
radius="xl"
color={near ? color : "edr-green"}
/>
</Box>
);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
function ForecastCard({ row }: { row: ForecastRow }) {
const { booking, rank, selected, blockedBy } = row;
const gov = booking.isGovernment;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: selected
? cardVar("edr-green", 3)
: cardVar("gray", 2),
background: selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
: "var(--mantine-color-white)",
opacity: selected ? 1 : 0.92,
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
<ThemeIcon
size={32}
radius="xl"
variant={selected && rank <= 3 ? "filled" : "light"}
color={gov ? "grape" : selected ? "edr-green" : "gray"}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{gov ? (
<Crown size={15} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{gov ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={10} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* score */}
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
{/* wagons + weight this booking adds */}
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
<Text size="xs" c="dimmed" w={64} ta="right">
{fmtTons(booking.weightTons)}
</Text>
{/* verdict */}
<Box w={150} style={{ textAlign: "right" }}>
{selected ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<Sparkles size={11} />}
>
Would board
</Badge>
) : (
<Tooltip
label={
blockedBy
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
: "Below the capacity line"
}
withArrow
>
<Badge variant="light" color="gray" radius="sm">
Waiting list
</Badge>
</Tooltip>
)}
</Box>
</Group>
</Group>
</Paper>
);
}
/** Cut line between the simulated batch and the simulated waiting list. */
function CutLine({ full }: { full: boolean }) {
return (
<Group gap="xs" my={2} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Forecast capacity line{full ? " · TRAIN FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
/**
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
* current pool and shows the projected winners + waiting list BEFORE document
* review closes. Not the real selection — the engine commits that when staff run
* the batch after the review window ends.
*/
export function ForecastPanel({ data, bookings }: Props) {
const limits = useMemo(() => limitsFromDetail(data), [data]);
const sim = useMemo(
() => simulateBatch(bookings, limits),
[bookings, limits],
);
const noCaps =
limits.maxWagons == null &&
limits.maxWeightTons == null &&
limits.maxLengthMeters == null;
return (
<Stack gap="lg">
{/* Header + explainer */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
<Group gap="sm">
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
<FlaskConical size={18} />
</ThemeIcon>
<Stack gap={2}>
<Group gap={8}>
<Text fw={700}>Forecast batch (simulated)</Text>
<Badge variant="light" color="violet" radius="sm" size="sm">
Preview
</Badge>
</Group>
<Text size="xs" c="dimmed" maw={520}>
What the batch engine would pick if it ran now greedy fill by
priority until the train is full. The real selection happens when
document review ends and staff run the batch.
</Text>
</Stack>
</Group>
<Group gap="lg">
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="edr-green.7">
{sim.selected.length}
</Text>
<Text size="xs" c="dimmed">
would board
</Text>
</Stack>
<Stack gap={0} align="flex-end">
<Text size="xl" fw={800} c="gray.7">
{sim.waiting.length}
</Text>
<Text size="xs" c="dimmed">
waiting list
</Text>
</Stack>
</Group>
</Group>
{/* Three capacity axes */}
<Group gap="lg" align="flex-end" wrap="wrap">
<AxisMeter
icon={TrainFront}
label="Wagon slots"
used={sim.usedWagons}
cap={limits.maxWagons}
fmt={(n) => `${n}`}
color="edr-green"
/>
<AxisMeter
icon={Scale}
label="Max pull weight"
used={sim.usedWeightTons}
cap={limits.maxWeightTons}
fmt={fmtTons}
color="orange"
/>
<AxisMeter
icon={Ruler}
label="Train length"
used={sim.usedLengthMeters}
cap={limits.maxLengthMeters}
fmt={fmtMeters}
color="blue"
/>
</Group>
{noCaps ? (
<Alert
color="yellow"
mt="md"
radius="md"
icon={<XCircle size={16} />}
>
No locomotive / capacity limits on this schedule yet forecast can't
draw the capacity line. Assign a locomotive to simulate the fill.
</Alert>
) : null}
</Paper>
{sim.rows.length === 0 ? (
<Paper radius="lg" withBorder p="xl">
<Text c="dimmed" ta="center">
No eligible bookings to forecast yet.
</Text>
</Paper>
) : (
<Stack gap={6}>
{/* WOULD BOARD */}
{sim.selected.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color="edr-green"
>
<Sparkles size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected batch{" "}
<Text span c="dimmed" fw={500}>
({sim.selected.length}) top priority, fits capacity
</Text>
</Text>
</Group>
{sim.selected.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
<CutLine full={sim.full} />
{/* WAITING LIST */}
{sim.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Layers size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Projected waiting list{" "}
<Text span c="dimmed" fw={500}>
({sim.waiting.length}) boards only if a slot frees up
</Text>
</Text>
</Group>
{sim.waiting.map((r) => (
<ForecastCard key={r.booking.id} row={r} />
))}
</Stack>
) : null}
{/* INELIGIBLE (expired / pending contract) */}
{sim.ineligible.length > 0 ? (
<Text size="xs" c="dimmed" mt={4}>
{sim.ineligible.length} booking
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
(expired or contract not signed).
</Text>
) : null}
</Stack>
)}
</Stack>
);
}
export default ForecastPanel;

View File

@@ -0,0 +1,614 @@
import { useMemo, useState } from "react";
import {
Box,
Group,
Paper,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
Boxes,
CheckCircle2,
Clock,
Container,
Crown,
FlaskConical,
Hourglass,
Layers,
ListOrdered,
Radio,
TrainFront,
Trophy,
XCircle,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
import { WindowPhasePill } from "./batchVisuals";
import { ForecastPanel } from "./ForecastPanel";
import { forecastIsLive } from "./batchForecast";
/**
* Priority Tracking tab — live, glanceable ranking of every booking on this
* schedule in the exact order the batch engine boards them (government first,
* then rule-engine priority score, then oldest). Bookings above the train's
* wagon-capacity line render as "selected" (green), below it as the waiting
* list; during the PAYMENT phase selected bookings show a live pay-window
* countdown. Purely presentational — data comes from the batch-board detail
* response the page already polls (+ socket-invalidates).
*/
type Props = {
data: BatchBoardScheduleDetail;
bookings: BatchBoardBookingDetail[];
};
const STATE_STYLE: Record<
BatchBoardBookingState,
{ label: string; color: string; icon: typeof CheckCircle2 }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock },
READY: { label: "Ready", color: "teal", icon: Hourglass },
WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass },
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
};
/** States that occupy a wagon slot on this train (i.e. are "in" the batch). */
const OCCUPIES_SLOT: BatchBoardBookingState[] = [
"ALLOCATED",
"SELECTED_FOR_BATCH",
"WAITING",
];
const cardVar = (color: string, shade: number) =>
`var(--mantine-color-${color}-${shade})`;
/** Highest score across the ranked pool → used to scale the priority mini-bar. */
function maxScore(bookings: BatchBoardBookingDetail[]): number {
return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0);
}
function FreightIcon({ type }: { type: string | null }) {
const Icon = type === "BULK" ? Boxes : Container;
return (
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
</Tooltip>
);
}
/** One ranked booking row rendered as a card, colored by its batch state. */
function RankedCard({
rank,
booking,
scoreMax,
phase,
isPayPhase,
}: {
rank: number;
booking: BatchBoardBookingDetail;
scoreMax: number;
phase: string | null;
isPayPhase: boolean;
}) {
const style = STATE_STYLE[booking.state];
const Icon = style.icon;
const selected = booking.state === "SELECTED_FOR_BATCH";
const allocated = booking.state === "ALLOCATED";
const expired = booking.state === "EXPIRED";
// Green surface for the winners (allocated + selected); muted for the rest.
const surfaceColor = allocated
? "edr-green"
: selected
? "edr-green"
: expired
? "red"
: "gray";
const scorePct =
scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
borderColor: cardVar(surfaceColor, allocated || selected ? 4 : 2),
background:
allocated || selected
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 60%)`
: expired
? cardVar("red", 0)
: "var(--mantine-color-white)",
opacity: expired ? 0.72 : 1,
transition: "background 200ms ease, border-color 200ms ease",
}}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
{/* Rank medallion */}
<ThemeIcon
size={34}
radius="xl"
variant={rank <= 3 ? "filled" : "light"}
color={
booking.isGovernment
? "grape"
: rank <= 3
? "edr-green"
: "gray"
}
style={{ flexShrink: 0, fontWeight: 800 }}
>
{booking.isGovernment ? (
<Crown size={16} />
) : (
<Text fw={800} size="sm">
{rank}
</Text>
)}
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={700} size="sm" truncate>
{booking.reference}
</Text>
<FreightIcon type={booking.freightType} />
{booking.isGovernment ? (
<Tooltip label="Government — boards first" withArrow>
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
<Crown size={11} />
</ThemeIcon>
</Tooltip>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{booking.company}
</Text>
</Stack>
</Group>
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
{/* Priority score with a mini strength bar */}
<Tooltip
label={`Priority score ${booking.priorityScore}${booking.isGovernment ? " + government bonus" : ""}`}
withArrow
>
<Stack gap={2} align="flex-end" w={92}>
<Group gap={4} wrap="nowrap">
<Trophy size={12} color={cardVar("edr-green", 6)} />
<Text fw={800} size="sm" c="edr-green.7">
{booking.priorityScore}
</Text>
</Group>
<Progress
value={scorePct}
size="xs"
color="edr-green"
w={92}
radius="xl"
/>
</Stack>
</Tooltip>
{/* Wagons */}
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
<TrainFront size={13} color={cardVar("gray", 6)} />
<Text fw={700} size="sm">
{booking.wagons}w
</Text>
</Group>
{/* State chip / pay countdown */}
<Box w={168} style={{ textAlign: "right" }}>
{selected && isPayPhase && booking.paymentDeadline ? (
<CountdownTimer
deadline={booking.paymentDeadline}
label="Pay in"
expiredText="Window closed"
size="sm"
/>
) : (
<Group gap={5} justify="flex-end" wrap="nowrap">
<ThemeIcon
size="sm"
radius="sm"
variant="light"
color={style.color}
>
<Icon size={12} />
</ThemeIcon>
<Text size="xs" fw={600} c={`${style.color}.7`}>
{style.label}
</Text>
</Group>
)}
</Box>
</Group>
</Group>
{/* phase hint only used for the a11y title; keeps `phase` referenced */}
<span hidden aria-hidden>
{phase}
</span>
</Paper>
);
}
/** The capacity cut line drawn between "in the batch" and "waiting list". */
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
const full = max != null && used >= max;
return (
<Group gap="xs" my={4} wrap="nowrap">
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
<Group gap={6} wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
<Layers size={12} />
</ThemeIcon>
<Text size="xs" fw={700} c="orange.7">
Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`}
{full ? " · FULL" : ""}
</Text>
</Group>
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
</Group>
);
}
export function PriorityTrackingTab({ data, bookings }: Props) {
const phase = data.windowPhase;
const isPayPhase = phase === "PAYMENT";
// Before the batch is committed (pre-window / open / doc-review) the real
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
// Default to it while it's live; let staff flip to the current live state.
const forecastAvailable = forecastIsLive(phase);
const [view, setView] = useState<"forecast" | "live">(
forecastAvailable ? "forecast" : "live",
);
const showForecast = forecastAvailable && view === "forecast";
// Rank exactly as the batch engine does: government first, then priority score
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
// backend uses). The board already returns them in this order, but re-sort
// defensively so the tab is correct even if the source order ever changes.
const ranked = useMemo(() => {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}, [bookings]);
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
// capacity line shows the wagons currently committed rather than a hard cap.
const maxWagons: number | null = null;
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
const capUsed = useMemo(
() =>
ranked
.filter((b) => OCCUPIES_SLOT.includes(b.state))
.reduce((sum, b) => sum + b.wagons, 0),
[ranked],
);
// Group for the lane layout.
const lanes = useMemo(() => {
const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state));
const waiting = ranked.filter(
(b) => b.state === "READY" || b.state === "PENDING_CONTRACT",
);
const expired = ranked.filter((b) => b.state === "EXPIRED");
return { inBatch, waiting, expired };
}, [ranked]);
if (ranked.length === 0) {
return (
<Paper radius="lg" withBorder p="xl">
<Group justify="center" gap="sm">
<ThemeIcon variant="light" color="gray" radius="xl" size="lg">
<ListOrdered size={18} />
</ThemeIcon>
<Text c="dimmed">No bookings on this schedule yet.</Text>
</Group>
</Paper>
);
}
let rankNo = 0;
const viewToggle = forecastAvailable ? (
<SegmentedControl
value={view}
onChange={(v) => setView(v as "forecast" | "live")}
size="sm"
radius="md"
data={[
{
value: "forecast",
label: (
<Group gap={6} wrap="nowrap">
<FlaskConical size={13} />
<Text size="xs" fw={600}>
Forecast
</Text>
</Group>
),
},
{
value: "live",
label: (
<Group gap={6} wrap="nowrap">
<Radio size={13} />
<Text size="xs" fw={600}>
Live state
</Text>
</Group>
),
},
]}
/>
) : null;
if (showForecast) {
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
<ForecastPanel data={data} bookings={ranked} />
</Stack>
);
}
return (
<Stack gap="lg">
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
{/* Header: phase + capacity meter */}
<Paper radius="lg" withBorder p="lg">
<Group justify="space-between" wrap="wrap" gap="md">
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
<Trophy size={18} />
</ThemeIcon>
<Stack gap={2}>
<Text fw={700}>Priority ranking</Text>
<Text size="xs" c="dimmed">
Government first, then rule-engine score, then earliest booked.
</Text>
</Stack>
</Group>
<Group gap="md">
{phase ? (
<WindowPhasePill phase={phase} cycleNo={data.bookingCycleNo} />
) : null}
{isPayPhase && data.paymentPhaseEndsAt ? (
<CountdownTimer
deadline={data.paymentPhaseEndsAt}
label="Payment window"
expiredText="Window closed"
size="md"
/>
) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? (
<CountdownTimer
deadline={data.docReviewEndsAt}
label="Doc review ends"
expiredText="Review over"
size="md"
/>
) : phase === "OPEN" && data.windowClosesAt ? (
<CountdownTimer
deadline={data.windowClosesAt}
label="Booking closes"
expiredText="Closed"
size="md"
/>
) : null}
</Group>
</Group>
{/* Capacity meter */}
<Box mt="md">
<Group justify="space-between" mb={4}>
<Text size="xs" c="dimmed" fw={600}>
Wagon capacity used
</Text>
<Text size="xs" fw={700}>
{data.capacity.allocatedWagons} allocated ·{" "}
{capUsed} in batch
</Text>
</Group>
<Progress.Root size="lg" radius="xl">
<Progress.Section
value={
capUsed > 0
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
: 0
}
color="edr-green"
/>
<Progress.Section
value={
capUsed > 0
? Math.min(
100,
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
)
: 0
}
color="orange"
/>
</Progress.Root>
</Box>
</Paper>
{/* Phase banner explaining what's happening now */}
<PhaseBanner phase={phase} />
{/* IN THE BATCH (green winners) — ranked */}
{lanes.inBatch.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="edr-green">
<CheckCircle2 size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
In the batch{" "}
<Text span c="dimmed" fw={500}>
({lanes.inBatch.length})
</Text>
</Text>
</Group>
{lanes.inBatch.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</Stack>
) : null}
<CapacityDivider used={capUsed} max={maxWagons} />
{/* WAITING LIST — ranked, below the line */}
{lanes.waiting.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="blue">
<Hourglass size={13} />
</ThemeIcon>
<Text fw={700} size="sm">
Waiting list{" "}
<Text span c="dimmed" fw={500}>
({lanes.waiting.length}) next in line if a slot frees up
</Text>
</Text>
</Group>
{lanes.waiting.map((b) => {
rankNo += 1;
return (
<RankedCard
key={b.id}
rank={rankNo}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
);
})}
</Stack>
) : null}
{/* EXPIRED */}
{lanes.expired.length > 0 ? (
<Stack gap={6}>
<Group gap="xs">
<ThemeIcon size="sm" radius="sm" variant="light" color="red">
<XCircle size={13} />
</ThemeIcon>
<Text fw={700} size="sm" c="red.7">
Expired{" "}
<Text span c="dimmed" fw={500}>
({lanes.expired.length}) missed the payment window
</Text>
</Text>
</Group>
{lanes.expired.map((b) => (
<RankedCard
key={b.id}
rank={0}
booking={b}
scoreMax={scoreMax}
phase={phase}
isPayPhase={isPayPhase}
/>
))}
</Stack>
) : null}
</Stack>
);
}
/** Contextual banner describing the current window phase in plain language. */
function PhaseBanner({ phase }: { phase: string | null }) {
const meta: Record<string, { color: string; text: string; icon: typeof Clock }> = {
OPEN: {
color: "edr-green",
icon: Clock,
text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.",
},
DOC_REVIEW: {
color: "yellow",
icon: Hourglass,
text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.",
},
PAYMENT: {
color: "blue",
icon: Clock,
text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.",
},
PRE_WINDOW: {
color: "gray",
icon: Hourglass,
text: "Window not open yet — bookings are pre-ranked and will compete when it opens.",
},
CLOSED_FOR_DAY: {
color: "gray",
icon: Hourglass,
text: "Window closed for the day — reopens for the next cycle if the train isn't full.",
},
DONE: {
color: "gray",
icon: CheckCircle2,
text: "Booking cycles finished for this train.",
},
};
const m = phase ? meta[phase] : null;
if (!m) return null;
const Icon = m.icon;
return (
<Paper
radius="md"
p="sm"
withBorder
style={{
background: cardVar(m.color, 0),
borderColor: cardVar(m.color, 2),
}}
>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color={m.color} radius="md">
<Icon size={16} />
</ThemeIcon>
<Text size="sm" fw={500} c={`${m.color}.8`}>
{m.text}
</Text>
</Group>
</Paper>
);
}
export default PriorityTrackingTab;

View File

@@ -0,0 +1,333 @@
import {
Alert,
Badge,
Button,
Divider,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtDate = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const DIRECTION_COLORS: Record<string, string> = {
IMPORT: "blue",
EXPORT: "teal",
DOMESTIC: "violet",
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
return (
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
{DIRECTION_LABELS[direction] ?? direction}
</Badge>
);
}
function BookingCell({ row }: { row: YardWorkBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
);
}
function WorkTable({
rows,
side,
trainHere,
onLoad,
onUnload,
pendingBookingId,
}: {
rows: YardWorkBookingRow[];
side: "load" | "unload";
trainHere: boolean;
onLoad: (bookingId: string) => void;
onUnload: (bookingId: string) => void;
pendingBookingId: string | null;
}) {
if (rows.length === 0) {
return (
<Text size="sm" c="dimmed">
{side === "load" ? "No bookings board here." : "No bookings alight here."}
</Text>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
const canAct = side === "load" ? row.canLoad : row.canUnload;
return (
<Table.Tr key={row.id}>
<Table.Td>
<BookingCell row={row} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
{timestamp ? (
<Text size="xs" c="dimmed">
{fmtDate(timestamp)}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{side === "load" ? (
<Tooltip
label={
trainHere
? "Confirm cargo loaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.loadedAt)}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onLoad(row.id)}
>
Load
</Button>
</Tooltip>
) : (
<Tooltip
label={
trainHere
? "Confirm cargo unloaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.arrivedAt)}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onUnload(row.id)}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
/**
* Per-yard load/unload worklist for one schedule — every trade direction. Each
* booking boards at its origin yard and alights at its destination yard; the
* operator confirms both while the train's last recorded checkpoint is at that
* yard (the server validates the position). Unloading stamps the booking's own
* arrival — ARRIVED for import/export, COMPLETED for intercity.
*/
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
const load = useMutation(
api.trainScheduling.loadScheduleBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadScheduleBooking.mutationOptions({
onSuccess: (result) => {
void invalidate();
toast({
title:
result.status === "COMPLETED"
? "Cargo unloaded — booking completed"
: "Cargo unloaded — booking arrived",
});
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
const data = yardWorkQuery.data;
const yards: YardWorkYard[] = data?.yards ?? [];
const trainAtYardId = data?.trainAtYardId ?? null;
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group gap="xs">
<MapPin size={18} />
<Text fw={700}>Yard load / unload</Text>
</Group>
{yardWorkQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading yard worklists
</Text>
</Group>
) : yardWorkQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
</Alert>
) : yards.length === 0 ? (
<Text size="sm" c="dimmed">
No bookings are assigned to this schedule yet.
</Text>
) : (
<>
<Text size="sm" c="dimmed">
What boards and alights at each stop. Confirm loading at a booking's
origin and unloading at its destination while the train is at that
yard — unloading stamps the booking's own arrival, even before the
train's final stop.
</Text>
{yards.map((yard, index) => {
const trainHere = trainAtYardId === yard.yardId;
return (
<Stack key={yard.yardId} gap="sm">
{index > 0 && <Divider />}
<Group gap="xs">
<Text fw={600}>{yard.yard}</Text>
{trainHere && (
<Badge
size="sm"
variant="light"
color="edr-green"
leftSection={<TrainFront size={12} />}
>
Train here
</Badge>
)}
</Group>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Board here
</Text>
<WorkTable
rows={yard.toLoad}
side="load"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingLoadId}
/>
</Stack>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Alight here
</Text>
<WorkTable
rows={yard.toUnload}
side="unload"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingUnloadId}
/>
</Stack>
</Stack>
);
})}
</>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,189 @@
import type {
BatchBoardBookingDetail,
BatchBoardScheduleDetail,
} from "@/types/trainScheduling";
/**
* Client-side forecast of what the batch engine WOULD select if it ran right now.
*
* The real selection only happens once the document-review window closes and staff
* hit "run batch". Before that, operations can only see the *current* per-booking
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
* the board can show the likely winners + waiting list live, during OPEN and
* DOC_REVIEW, before anything is committed.
*
* It mirrors the engine (booking-batch.service): rank government-first, then
* priority score desc, then oldest booked; greedily board each booking while it
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
* train length (metres). The first booking that busts any axis, and everyone after
* it, drops to the waiting list. Purely a projection; the server stays the source
* of truth for the real run.
*/
export interface ForecastLimits {
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
maxWagons: number | null;
/** Locomotive max pull weight in tons, or null. */
maxWeightTons: number | null;
/** Max train length in metres, or null. */
maxLengthMeters: number | null;
}
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
export type BlockingAxis = "wagons" | "weight" | "length";
export interface ForecastRow {
booking: BatchBoardBookingDetail;
/** 1-based rank across the whole eligible pool. */
rank: number;
/** True → boards in the simulated batch; false → simulated waiting list. */
selected: boolean;
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
cumulativeWagons: number;
cumulativeWeightTons: number;
cumulativeLengthMeters: number;
/** If not selected, the first axis that would have overflowed. */
blockedBy: BlockingAxis | null;
}
export interface ForecastResult {
rows: ForecastRow[];
selected: ForecastRow[];
waiting: ForecastRow[];
/** Bookings excluded from the sim entirely (expired / no signed contract). */
ineligible: BatchBoardBookingDetail[];
limits: ForecastLimits;
/** Totals of the simulated batch. */
usedWagons: number;
usedWeightTons: number;
usedLengthMeters: number;
/** True once any axis is at/over its cap — train is "full" in the sim. */
full: boolean;
}
/** Engine rank order: government first, then priority desc, then oldest booked. */
export function rankBookings(
bookings: BatchBoardBookingDetail[],
): BatchBoardBookingDetail[] {
const time = (b: BatchBoardBookingDetail) =>
b.fullyExecutedAt
? new Date(b.fullyExecutedAt).getTime()
: Number.MAX_SAFE_INTEGER;
return [...bookings].sort((a, b) => {
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
if (b.priorityScore !== a.priorityScore)
return b.priorityScore - a.priorityScore;
return time(a) - time(b);
});
}
/**
* A booking can compete in the batch only once its contract is signed. Expired
* bookings and pending-contract bookings never board, so they're pulled out of the
* sim (surfaced separately so they don't vanish from the board).
*/
function isEligible(b: BatchBoardBookingDetail): boolean {
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
function overflows(used: number, add: number, cap: number | null): boolean {
return cap != null && used + add > cap;
}
export function simulateBatch(
bookings: BatchBoardBookingDetail[],
limits: ForecastLimits,
): ForecastResult {
const ranked = rankBookings(bookings);
const eligible = ranked.filter(isEligible);
const ineligible = ranked.filter((b) => !isEligible(b));
const rows: ForecastRow[] = [];
let wagons = 0;
let weight = 0;
let length = 0;
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
let full = false;
eligible.forEach((booking, i) => {
let blockedBy: BlockingAxis | null = null;
if (!full) {
if (overflows(wagons, booking.wagons, limits.maxWagons))
blockedBy = "wagons";
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
blockedBy = "weight";
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
blockedBy = "length";
}
// Strict fill: the first booking that doesn't fit closes the train, so lower-
// priority bookings can't leapfrog it even if they'd individually fit. Matches
// the engine's greedy pass.
const selected = !full && blockedBy === null;
if (selected) {
wagons += booking.wagons;
weight = round2(weight + booking.weightTons);
length = round2(length + booking.lengthMeters);
} else {
full = true;
}
rows.push({
booking,
rank: i + 1,
selected,
cumulativeWagons: selected ? wagons : 0,
cumulativeWeightTons: selected ? weight : 0,
cumulativeLengthMeters: selected ? length : 0,
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
});
});
return {
rows,
selected: rows.filter((r) => r.selected),
waiting: rows.filter((r) => !r.selected),
ineligible,
limits,
usedWagons: wagons,
usedWeightTons: weight,
usedLengthMeters: length,
full,
};
}
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
if (limits.maxWagons != null) return "wagons";
if (limits.maxWeightTons != null) return "weight";
return "length";
}
/** Pull the three capacity caps off the board detail response. */
export function limitsFromDetail(
data: BatchBoardScheduleDetail,
): ForecastLimits {
return {
maxWagons: data.capacity.maxWagons ?? null,
maxWeightTons:
data.capacity.maxWeightTons ??
data.locomotive?.maxPullWeightTons ??
null,
maxLengthMeters:
data.capacity.maxLengthMeters ??
data.locomotive?.maxTrainLengthMeters ??
null,
};
}
/**
* The forecast is meaningful before the batch is committed — i.e. while bookings
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
* real per-booking state is the truth, so we stop showing the projection.
*/
export function forecastIsLive(
phase: BatchBoardScheduleDetail["windowPhase"],
): boolean {
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
}

View File

@@ -11,6 +11,7 @@ import {
Table,
Tabs,
Text,
Tooltip,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText } from 'lucide-react';
@@ -22,7 +23,7 @@ import {
type ContainerItem,
type ContainerItemStage,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface ContainerItemsModalProps {
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
{ value: 'ALL', label: 'All' },
{ value: 'RECEIVED', label: 'Received' },
{ value: 'GRN', label: "GRN'd" },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'LOADED', label: 'Loaded' },
{ value: 'LEFT', label: 'Left' },
{ value: 'DELIVERED', label: 'Delivered' },
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
PENDING: 'gray',
RECEIVED: 'blue',
GRN: 'teal',
ASSIGNED: 'indigo',
LOADED: 'grape',
LEFT: 'orange',
DELIVERED: 'green',
};
/** Loadable = not yet on a truck (before LOADED). */
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
const isLoadable = (i: ContainerItem) =>
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
const { toast } = useToast();
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
// Only arrived, not-yet-departed trucks can be loaded.
const truckOptions = trucks
.filter((t) => !(t as { departedAt?: string }).departedAt)
.filter(
(t) =>
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
!(t as { departedAt?: string }).departedAt,
)
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
const loadMutation = useMutation({
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const requestSign = async () => {
try {
const res = await warehouseService.requestHandoverSignature(bookingId as string);
queryClient.invalidateQueries({ queryKey: itemsKey });
if (res.alreadySigned) {
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
} else {
toast({
title: 'Handover not signed',
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
});
}
} catch (e) {
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
}
};
const openExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
}
};
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
<Table.Td ta="right">
{i.truckAssignmentId && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<FileText size={13} />}
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
{i.loaded && i.truckAssignmentId && (
<Tooltip
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
disabled={i.handoverSigned}
withArrow
multiline
w={240}
>
Exit Paper
</Button>
<Button
size="compact-xs"
variant="light"
color={i.handoverSigned ? 'orange' : 'gray'}
leftSection={<FileText size={13} />}
onClick={() =>
i.handoverSigned
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
: requestSign()
}
>
Exit Paper
</Button>
</Tooltip>
)}
</Table.Td>
</Table.Tr>

View File

@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
toast({ title: 'Warehouse updated' });
} else {
await createMutation.mutateAsync(payload);
await createMutation.mutateAsync({ ...payload, status: form.status });
toast({ title: 'Warehouse created' });
}
onClose();
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
allowDeselect={false}
/>
{isEdit && (
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
)}
<Select
label="Status"
data={statusOptions}
value={form.status}
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
allowDeselect={false}
/>
</Group>
<TextInput
@@ -169,7 +167,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
<Group grow>
<NumberInput
label="Capacity weight (kg)"
label="Capacity weight (t)"
placeholder="Optional"
min={0}
value={form.capacityWeight}

View File

@@ -132,7 +132,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
<Group grow>
<NumberInput
label="Capacity weight (kg)"
label="Capacity weight (t)"
placeholder="Optional"
min={0}
value={form.capacityWeight}

View File

@@ -132,7 +132,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
<Group grow>
<NumberInput
label="Capacity weight (kg)"
label="Capacity weight (t)"
placeholder="Optional"
min={0}
value={form.capacityWeight}

View File

@@ -174,13 +174,13 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
{hasWeightLoss && (
<Group grow mt="xs">
<NumberInput
label="Expected weight (kg)"
label="Expected weight (t)"
min={0}
value={expectedWeight}
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
/>
<NumberInput
label="Actual weight (kg)"
label="Actual weight (t)"
min={0}
value={actualWeight}
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}

View File

@@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Handover reference" value={handoverReference || '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} t`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
</SimpleGrid>

View File

@@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Item" value={itemLabel(result)} />
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
<DetailRow label="Weight" value={`${formatNumber(result.weight)} t`} />
</SimpleGrid>
<Divider label="Location" labelPosition="left" />

View File

@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
import { LoadInventoryModal } from './LoadInventoryModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { ReserveInventoryModal } from './ReserveInventoryModal';
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
onLastMile?: (item: WarehouseInventoryItem) => void;
}
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
/** Inventory table + all lifecycle actions (advance / move / history). */
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
const { toast } = useToast();
const [busyId, setBusyId] = useState<string | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
const storeInventory = async (item: WarehouseInventoryItem) => {
setBusyId(item.id);
try {
const stored = await storeMutation.mutateAsync(item.id);
const stored = await storeMutation.mutateAsync({ id: item.id });
toast({
title: 'Inventory stored',
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
switch (action) {
case 'store':
return storeInventory(item);
case 'reserve':
setReserveItem(item);
return;
case 'ready-for-loading':
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
case 'load':
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
</Stack>
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<ReserveInventoryModal
opened={Boolean(reserveItem)}
onClose={() => setReserveItem(null)}
item={reserveItem}
/>
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
<InventoryHistoryModal
opened={Boolean(historyItem)}

View File

@@ -67,7 +67,7 @@ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModal
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
<NumberInput
label="Loaded weight (kg)"
label="Loaded weight (t)"
placeholder="Defaults to item weight"
min={0}
value={loadedWeight}

View File

@@ -1,22 +1,26 @@
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Loader,
Select,
Paper,
Stack,
Table,
Tabs,
Text,
} from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TrainFront } from 'lucide-react';
import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
import {
warehouseService,
type LoadableTrain,
type TrainLoadableItem,
} from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
const STAGE_COLOR: Record<string, string> = {
@@ -27,226 +31,277 @@ const STAGE_COLOR: Record<string, string> = {
LOADED: 'green',
};
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
interface BookingGroup {
bookingId: string | null;
bookingReference: string | null;
customerName: string | null;
items: TrainLoadableItem[];
}
function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] {
const map = new Map<string, BookingGroup>();
for (const i of items) {
const key = i.bookingId ?? i.bookingReference ?? 'unknown';
let g = map.get(key);
if (!g) {
g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] };
map.set(key, g);
}
g.items.push(i);
}
return [...map.values()];
}
/**
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
* already-allocated wagons. Loading follows train + wagon allocation: only items
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
* Load to Train — a datatable of allocated EXPORT trains. Expand a train to see
* the bookings allocated to it; expand a booking to see its containers/cargoes
* and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an
* allocated wagon are selectable.
*/
export function LoadToTrainPanel() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [scheduleId, setScheduleId] = useState<string | null>(null);
const [tab, setTab] = useState('received');
const [selected, setSelected] = useState<string[]>([]);
const trainsKey = ['loadable-trains'];
const { data: trains = [], isLoading: trainsLoading } = useQuery({
queryKey: trainsKey,
const { data: trains = [], isLoading } = useQuery({
queryKey: ['loadable-trains'],
queryFn: () => warehouseService.getLoadableTrains(),
});
const itemsKey = ['train-loadable-items', scheduleId];
const { data: items = [], isLoading } = useQuery({
queryKey: itemsKey,
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
enabled: Boolean(scheduleId),
});
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
const visible = tab === 'loaded' ? loaded : received;
const trainOptions = trains.map((t) => ({
value: t.scheduleId,
label:
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
(t.origin || t.destination ? ` · ${t.origin ?? '?'}${t.destination ?? '?'}` : '') +
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
}));
const selectableVisible = visible.filter((i) => i.loadable);
const allSelected =
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggle = (id: string) =>
setExpanded((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
if (isLoading) {
return (
<Group justify="center" py="lg">
<Loader />
</Group>
);
}
if (trains.length === 0) {
return (
<Alert color="gray" variant="light">
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th w={40} />
<Table.Th>Train</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th ta="center">Ready</Table.Th>
<Table.Th ta="center">Loaded</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trains.map((t) => (
<TrainRow
key={t.scheduleId}
train={t}
expanded={expanded.has(t.scheduleId)}
onToggle={() => toggle(t.scheduleId)}
/>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) {
const { data: items = [], isLoading } = useQuery({
queryKey: ['train-loadable-items', train.scheduleId],
queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId),
enabled: expanded,
});
const bookings = useMemo(() => groupByBooking(items), [items]);
const route =
train.origin || train.destination ? `${train.origin ?? '?'}${train.destination ?? '?'}` : '—';
return (
<>
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<TrainFront size={16} />
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
</Group>
</Table.Td>
<Table.Td>{route}</Table.Td>
<Table.Td ta="center">
<Badge color="blue" variant="light">
{train.readyCount}
</Badge>
</Table.Td>
<Table.Td ta="center">
<Badge color="green" variant="light">
{train.loadedCount}
</Badge>
</Table.Td>
</Table.Tr>
{expanded && (
<Table.Tr>
<Table.Td colSpan={5} p={0}>
<Box p="sm" bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : bookings.length === 0 ? (
<Alert color="gray" variant="light">
No arrived containers/cargoes allocated to this train yet.
</Alert>
) : (
<Stack gap="xs">
{bookings.map((b) => (
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
))}
</Stack>
)}
</Box>
</Table.Td>
</Table.Tr>
)}
</>
);
}
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length;
const selectable = booking.items.filter((i) => i.loadable);
const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id));
const toggleItem = (id: string) =>
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
const toggleAll = () =>
setSelected((s) =>
allSelected
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id),
);
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected),
onSuccess: (r) => {
queryClient.invalidateQueries({ queryKey: itemsKey });
queryClient.invalidateQueries({ queryKey: trainsKey });
queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] });
queryClient.invalidateQueries({ queryKey: ['loadable-trains'] });
setSelected([]);
toast({
title: 'Loaded onto train',
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
});
toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` });
},
onError: (e) =>
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const renderRow = (i: TrainLoadableItem) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox
checked={selected.includes(i.id)}
onChange={() => toggle(i.id)}
disabled={!i.loadable}
/>
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
<Table.Td>{i.customerName ?? '—'}</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
);
return (
<Stack gap="md">
<Group align="flex-end" justify="space-between">
<Select
label="Train"
description="Allocated EXPORT trains awaiting loading"
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
data={trainOptions}
value={scheduleId}
onChange={(v) => {
setScheduleId(v);
setSelected([]);
setTab('received');
}}
disabled={trainOptions.length === 0}
leftSection={<TrainFront size={16} />}
w={460}
searchable
/>
<Paper withBorder radius="sm" p="xs">
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
<Group gap="xs" wrap="nowrap">
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
<Text size="sm" c="dimmed">
{booking.customerName ?? '—'}
</Text>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge variant="light" color="blue">
{booking.items.length} item(s)
</Badge>
{loadedCount > 0 && (
<Badge variant="light" color="green">
{loadedCount} loaded
</Badge>
)}
</Group>
</Group>
{!scheduleId ? (
<Alert color="gray" variant="light">
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
after train and wagon allocation.
</Alert>
) : (
<>
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
<Tabs.List>
<Tabs.Tab
value="received"
rightSection={
<Badge size="xs" variant="light" color="blue">
{received.length}
{open && (
<>
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
<Table.Thead>
<Table.Tr>
<Table.Th w={36}>
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectable.length === 0}
/>
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{booking.items.map((i) => (
<Table.Tr key={i.id}>
<Table.Td>
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(i.id)} disabled={!i.loadable} />
</Table.Td>
<Table.Td>
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
</Table.Td>
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
<Table.Td>{weight(i.weight)}</Table.Td>
<Table.Td>
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
{i.status.replace(/_/g, ' ')}
</Badge>
}
>
Received
</Tabs.Tab>
<Tabs.Tab
value="loaded"
rightSection={
<Badge size="xs" variant="light" color="green">
{loaded.length}
</Badge>
}
>
Loaded
</Tabs.Tab>
</Tabs.List>
</Tabs>
{isLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : visible.length === 0 ? (
<Alert color="gray" variant="light">
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
</Alert>
) : (
<Table.ScrollContainer minWidth={900}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>
{tab === 'received' && (
<Checkbox
checked={allSelected}
indeterminate={!allSelected && selected.length > 0}
onChange={toggleAll}
disabled={selectableVisible.length === 0}
/>
)}
</Table.Th>
<Table.Th>Container / Cargo</Table.Th>
<Table.Th>Goods</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Inspection</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
</Table>
</Table.ScrollContainer>
)}
{tab === 'received' && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
</Text>
<Button
color="edr-green"
leftSection={<TrainFront size={16} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
)}
</>
</Table.Td>
<Table.Td>
{i.wagonNumber ? (
<Badge variant="outline" color="indigo">
{i.wagonNumber}
</Badge>
) : (
<Text size="xs" c="red">
Not allocated
</Text>
)}
</Table.Td>
<Table.Td>
{i.inspectionStatus ? (
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
{i.inspectionStatus}
</Badge>
) : (
'—'
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<Group justify="space-between" align="center" mt="xs">
<Text size="xs" c="dimmed">
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
</Text>
<Button
size="compact-sm"
color="edr-green"
leftSection={<TrainFront size={14} />}
disabled={selected.length === 0}
loading={loadMutation.isPending}
onClick={() => loadMutation.mutate()}
>
Load {selected.length || ''} onto train
</Button>
</Group>
</>
)}
</Stack>
</Paper>
);
}

View File

@@ -7,6 +7,7 @@ import {
Checkbox,
Group,
Loader,
Menu,
Modal,
NumberInput,
ScrollArea,
@@ -20,6 +21,7 @@ import {
Tooltip,
} from '@mantine/core';
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
ClipboardCheck,
@@ -27,6 +29,8 @@ import {
FileText,
History,
Info,
MapPin,
MoreHorizontal,
PackageCheck,
PackageOpen,
PackageSearch,
@@ -61,7 +65,6 @@ import type {
} from '@/types/warehouse';
import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { ContainerItemsModal } from './ContainerItemsModal';
import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal';
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
import { InventoryHistoryModal } from './InventoryHistoryModal';
import { InventoryWorkbench } from './InventoryWorkbench';
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
import { MoveInventoryModal } from './MoveInventoryModal';
import { ReleaseOrderModal } from './ReleaseOrderModal';
import { StoreInventoryModal } from './StoreInventoryModal';
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
import { openPdfBlob } from './pdf';
@@ -520,14 +525,14 @@ function TruckEntranceFields({
{value.weighingRequired && (
<Group grow>
<NumberInput
label="Gross weight (kg)"
label="Gross weight (t)"
required
min={0}
value={value.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
label="Exit tare weight (t)"
required
min={0}
value={value.exitTareWeightKg}
@@ -596,7 +601,7 @@ function TruckEntranceFields({
/>
</Group>
<NumberInput
label="Net weight (kg)"
label="Net weight (t)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const inspectMutation = useMutation(
api.warehouses.bulkMarkInspected.mutationOptions(),
);
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null);
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected;
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
<Eye size={16} />
</ActionIcon>
</Tooltip>
{r.currentStatus === 'UNLOADED' && (
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
color="blue"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
color="yellow"
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
Store
</Button>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="orange"
loading={busyId === r.id}
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
>
Ready Pickup
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<>
<Button
size="compact-xs"
variant="light"
color="yellow"
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
</>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"
variant="light"
color="green"
loading={busyId === r.id}
onClick={() => setLoadTruckItem(toInventoryItem(r))}
>
Truck_dispatch
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Exit Paper
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={() => setDeliverItem(toInventoryItem(r))}
>
Deliver
</Button>
)}
{r.inspectionStatus === 'PASSED' && (
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<FileText size={14} />}
onClick={() => openHandoverDocument(r)}
>
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
</Button>
)}
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
Inspect / Report
</Button>
<Tooltip label="Storage / fee preview" withArrow>
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
<PackageCheck size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label="History" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
<History size={16} />
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
<MoreHorizontal size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{r.currentStatus === 'UNLOADED' && (
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
Store
</Menu.Item>
)}
{r.currentStatus !== 'UNLOADED' && (
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
Move
</Menu.Item>
)}
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
Exit paper
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
)}
{r.inspectionStatus === 'PASSED' && (
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
</Menu.Item>
)}
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
<Menu.Divider />
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
Storage / fee preview
</Menu.Item>
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
History
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Table.Td>
</Table.Tr>
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
inventoryId={feeItem?.id ?? null}
/>
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
<ContainerItemsModal
opened={Boolean(containerItemsItem)}
onClose={() => setContainerItemsItem(null)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -78,7 +55,7 @@ const lineValue = (notes: string | null | undefined, label: string) => {
};
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
if (!value) return '';
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : '';
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
// Per-container cargo weights — the truck's net (gross tare) must equal the
// total cargo weight of the containers selected as loaded on it.
const { data: containerWeights = [] } = useQuery({
queryKey: ['release-container-weights', bookingId],
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
// operator types the plate manually in the field below.
const truckSelectOptions = assignedTruckOptions;
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
// operator selects the containers loaded on the truck at exit.
const hasContainerWeights = containerWeights.length > 0;
const containerWeightByNumber = new Map(
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
);
const containerSelectData = containerWeights.map((c) => ({
value: c.containerNumber,
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
}));
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
const selectedCargoWeight = Number(
selectedContainerNumbers
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
.toFixed(3),
);
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
: item?.weight == null
? netWeight
: Number(item.weight);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
return;
}
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
return;
}
if (isExitStep && systemNetWeight === '') {
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
return;
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={truckSelectOptions}
disabled={isTruckIdentityLocked}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
placeholder="Select the assigned truck"
searchable
clearable
// Enabled at arrival so the operator picks which assigned truck came;
// only locked on the exit (leaving) step once identity is captured.
disabled={isEntranceLocked}
data={truckSelectOptions}
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}}
/>
)}
<Group grow>
<TextInput
label="Truck plate number"
@@ -376,34 +385,55 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group>
<Group grow>
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
<Group grow align="flex-start">
{hasContainerWeights ? (
<MultiSelect
label="Containers on this truck"
description={
isExitStep
? 'Select the containers loaded on this truck — their cargo weight must match gross tare.'
: 'Containers this truck will carry.'
}
placeholder="Select containers"
searchable
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
/>
) : (
<Stack gap={6}>
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
{containerNumbers.map((containerNumber, index) => (
<TextInput
key={index}
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
value={containerNumber}
onChange={(e) =>
setContainerNumbers((numbers) =>
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
)
}
readOnly={isTruckIdentityLocked}
/>
))}
</SimpleGrid>
</Stack>
)}
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
value={systemNetWeight}
readOnly
/>
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
</Group>

View File

@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
import { Info } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
import type { WarehouseInventoryItem } from '@/types/warehouse';
import { extractErrorMessage } from './options';
interface StoreInventoryModalProps {
opened: boolean;
onClose: () => void;
item: WarehouseInventoryItem | null;
}
/**
* Store an unloaded import item. The operator may pick warehouse → yard → zone
* explicitly; leaving them blank falls back to the backend auto allocation.
*/
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
const { toast } = useToast();
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const [warehouseId, setWarehouseId] = useState('');
const [yardId, setYardId] = useState('');
const [zoneId, setZoneId] = useState('');
useEffect(() => {
if (opened) {
setWarehouseId('');
setYardId('');
setZoneId('');
}
}, [opened]);
const warehousesQuery = useQuery(
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
);
const yardsQuery = useQuery(
api.warehouses.listYards.queryOptions({
input: { warehouseId },
enabled: Boolean(warehouseId),
}),
);
const zonesQuery = useQuery(
api.warehouses.listZones.queryOptions({
input: { yardId },
enabled: Boolean(yardId),
}),
);
const warehouseOptions = useMemo(
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
[warehousesQuery.data],
);
const yardOptions = useMemo(
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
[yardsQuery.data],
);
const zoneOptions = useMemo(
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
[zonesQuery.data],
);
const isManual = Boolean(warehouseId || yardId || zoneId);
const manualComplete = Boolean(warehouseId && yardId && zoneId);
const handleSubmit = async () => {
if (!item) return;
if (isManual && !manualComplete) {
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
return;
}
try {
await storeMutation.mutateAsync({
id: item.id,
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
});
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
onClose();
} catch (error) {
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
}
};
return (
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="blue" variant="light">
<Text size="sm">
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
blank to let the system auto-allocate by rule / available capacity.
</Text>
</Alert>
<Select
label="Warehouse"
placeholder="Auto-allocate"
searchable
clearable
data={warehouseOptions}
value={warehouseId || null}
onChange={(v) => {
setWarehouseId(v ?? '');
setYardId('');
setZoneId('');
}}
/>
<Select
label="Yard"
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
searchable
clearable
disabled={!warehouseId}
data={yardOptions}
value={yardId || null}
onChange={(v) => {
setYardId(v ?? '');
setZoneId('');
}}
/>
<Select
label="Zone"
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
searchable
clearable
disabled={!yardId}
data={zoneOptions}
value={zoneId || null}
onChange={(v) => setZoneId(v ?? '')}
/>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
{manualComplete ? 'Store here' : 'Store (auto)'}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
const actionColor: Record<InventoryAction, string> = {
store: 'blue',
reserve: 'grape',
'ready-for-loading': 'cyan',
load: 'teal',
dispatch: 'edr-green',

View File

@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
};
/**
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
*/
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
if (responseData instanceof Blob) {
try {
const text = await responseData.text();
const parsed = JSON.parse(text) as Record<string, unknown>;
const raw = parsed?.message ?? parsed?.error;
if (Array.isArray(raw)) return raw.join(', ');
if (raw) return String(raw);
} catch {
/* not JSON — fall through */
}
}
return extractErrorMessage(error, fallback);
};

View File

@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
documents: (id: string) =>
["customers", "detail", id, "documents"] as const,
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
changeRequests: (id: string) =>
["customers", "detail", id, "change-requests"] as const,
},
INVOICES: {

View File

@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
PROFILE_STATUS: (profileId: string) =>
`/companies/company-profiles/${profileId}/status`,
CHANGE_REQUESTS: (companyId: string) =>
`/companies/${companyId}/change-requests`,
CHANGE_REQUEST_APPROVE: (id: string) =>
`/companies/change-requests/${id}/approve`,
CHANGE_REQUEST_REJECT: (id: string) =>
`/companies/change-requests/${id}/reject`,
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
`/bookings/by-company/${id}/customer-view`,
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
@@ -326,6 +332,11 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
BOOKING_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>

View File

@@ -15,11 +15,56 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
// The two carousel window lists share the MyBookingWindow-shaped row and can be
// patched in place. The batch board is a richer, differently-shaped view, so it
// stays on a (debounced) invalidate.
const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]);
/** Shape shared by both carousel window lists (all-lanes + contract-scoped). */
interface WindowRow {
scheduleId: string;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
}
function isWindowKey(key: readonly unknown[]): boolean {
return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1]));
}
/**
* Subscribes to live booking-window pushes for staff. Every phase transition
* the window engine applies invalidates the GL windows carousel and the batch
* board, so both flip the moment the backend does — polling stays only as a
* fallback.
* Fold a server phase push onto a cached window row, recomputing isOpenNow the
* same way the server does (phase OPEN + status OPEN) so live-patched state can
* never disagree with a fresh REST fetch on refresh.
*/
function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent): T {
return {
...row,
windowPhase: event.phase,
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
bookingCycleNo: event.bookingCycleNo,
isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
windowOpensAt: event.windowOpensAt,
windowClosesAt: event.windowClosesAt,
docReviewEndsAt: event.docReviewEndsAt,
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
departureDate: event.scheduledDepartureDate ?? row.departureDate,
};
}
/**
* Subscribes to live booking-window pushes for staff. A phase transition carries
* the schedule's full new state; we fold it straight into the carousel window
* lists with setQueriesData rather than invalidating — same rationale as the
* portal hook (no per-push refetch storm; live + refreshed state agree, killing
* the refresh-jump). The batch board is a different-shaped view, so it keeps a
* debounced invalidate, as do pushes for schedules not present in any list.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
@@ -47,19 +92,60 @@ export function useBookingWindowSocket(enabled: boolean = true) {
console.debug("[booking-windows] socket disconnected:", reason),
);
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "all-booking-windows"],
});
qc.invalidateQueries({
// Coalesce the batch-board refresh (and the unknown-schedule fallback) so a
// burst of pushes triggers at most one invalidation per window.
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleRefetch = (includeWindowLists: boolean) => {
if (refetchTimer) return;
refetchTimer = setTimeout(() => {
refetchTimer = null;
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
if (includeWindowLists) {
void qc.invalidateQueries({
predicate: (q) => isWindowKey(q.queryKey),
});
}
}, 800);
};
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(event: BookingWindowPhaseEvent) => {
let patchedSomewhere = false;
qc.setQueriesData<WindowRow[]>(
{ predicate: (q) => isWindowKey(q.queryKey) },
(rows) => {
if (!rows) return rows;
let changed = false;
const next = rows.map((row) => {
if (row.scheduleId !== event.scheduleId) return row;
changed = true;
patchedSomewhere = true;
return applyEvent(row, event);
});
return changed ? next : rows;
},
);
// Refresh the batch-board DETAIL for the schedule that transitioned so the
// Priority Tracking tab reranks + updates its countdowns immediately (the
// detail is a different shape from the list — invalidate, don't patch).
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
});
// Always refresh the batch board (different shape, not patched). When the
// schedule wasn't in any window list either, refresh those too so a newly
// announced window surfaces. Both debounced — no per-push stampede.
scheduleRefetch(!patchedSomewhere);
},
);
return () => {
if (refetchTimer) clearTimeout(refetchTimer);
socket.off();
socket.disconnect();
};

View File

@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "In Transit",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
ARRIVED: {
label: "Arrived",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
COMPLETED: {
label: "Completed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
@@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
color: "text-sky-600",
stage: 4,
},
ARRIVED: {
title: "Arrived",
description: "Cargo unloaded at its destination yard.",
color: "text-emerald-600",
stage: 4,
},
COMPLETED: {
title: "Completed",
description: "Booking fulfilled.",
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Operations",
statuses: ["PAID", "IN_TRANSIT"],
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
},
{ label: "Done", statuses: ["COMPLETED"] },
] as const;

View File

@@ -39,7 +39,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
booking.serviceType?.label ??
booking.serviceType?.name ??
booking.serviceType?.code,
serviceTypeBonus: booking.serviceType?.priorityBonusPoints ?? 0,
trainScheduleId: booking.trainScheduleId ?? null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,

View File

@@ -1,6 +1,7 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
FolderOpen,
Layers,
LayoutGrid,
Milestone,
@@ -37,6 +38,7 @@ import {
BookingContractSummaryCard,
BookingContainerUnitsCard,
ClearanceReviewSection,
BookingDocumentsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
const showTabs = showClearanceTab || isGeneralContract;
// The Documents tab is always available — every booking can accrue clearance,
// customs-workflow, invoice or notice files — so the tab bar always renders.
const requestedTab = searchParams.get("tab");
const activeTab =
requestedTab === "clearance" && showClearanceTab
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders"
: "overview";
: requestedTab === "documents"
? "documents"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
)}
<Grid gap="lg">
{/* LEFT — primary content, split into tabs to keep each view focused */}
{/* LEFT — primary content, split into tabs to keep each view focused.
The Documents tab is always present, so the tab bar always renders. */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{showTabs ? (
<Tabs
<Tabs
value={activeTab}
onChange={setActiveTab}
variant="pills"
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
Customer clearance
</Tabs.Tab>
)}
<Tabs.Tab
value="documents"
leftSection={<FolderOpen size={16} />}
>
Documents
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
/>
</Tabs.Panel>
)}
<Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel>
</Tabs>
) : (
<OverviewPanel booking={booking} row={row} />
)}
</Grid.Col>
{/* RIGHT — sticky action / summary rail */}

View File

@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useFileViewer } from "@/hooks/useFileViewer";
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
<ClearanceHero contract={contract} stats={stats} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? (
<Alert
color="blue"

View File

@@ -1,6 +1,7 @@
import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
Card,
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
import {
BookingStatusBadge,
ChangeRequestPendingBadge,
ChangeRequestReview,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
{
id: "type",
header: "Role",
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
},
{
id: "reference",
header: "Reference",
cell: ({ row }) => (
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
<div className="space-y-2">
<ProfileTypeBadge type={row.original.type} />
<Text size="sm" fw={600} c="edr-text">
{row.original.reference}
</Text>
</div>
),
},
{
id: "businessLicense",
header: "Business license",
cell: ({ row }) => (
<Text size="sm" c="dimmed">
{row.original.businessLicense || "—"}
</Text>
),
id: "licenseFiles",
header: "License documents",
cell: ({ row }) => {
const files = row.original.licenseFiles ?? [];
if (files.length === 0) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
return (
<Stack gap={4}>
{files.map((f) => (
<Group key={f.id} gap={6} wrap="nowrap">
<ActionIcon
size="sm"
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
>
<Eye size={14} />
</ActionIcon>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
view({
name: f.name,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
style={{
maxWidth: 170,
textAlign: "left",
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removing
</Badge>
)}
</Group>
))}
</Stack>
);
},
},
{
id: "status",
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
),
},
],
[],
[view],
);
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
@@ -501,13 +564,13 @@ 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} />
<CompanyStatusBadge status={company.status} />
<ChangeRequestPendingBadge companyId={company.id} />
</Group>
}
/>
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
{/* OVERVIEW */}
<Tabs.Panel value="overview" pt="lg">
<Stack gap="lg">
<ChangeRequestReview company={company} />
<KpiStrip
items={[
{
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
<ProfileChips profiles={company.companyProfiles} />
</Group>
<Box style={{ overflowX: "auto" }} w="100%">
<Box miw={860}>
<Box miw={1040}>
<DataTable
columns={profileColumns}
data={company.companyProfiles}
@@ -641,9 +706,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
}
/>
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
message: "Failed to load documents.",
onRetry: () => void documentsQuery.refetch(),
}
: undefined
}
/>
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
</Text>
<Stack gap="md">
{licenseProfiles.map((p) => (
<Stack key={p.id} gap={4}>
<Stack key={p.id} gap={6}>
<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">
<Group key={f.id} gap={8} wrap="nowrap">
<Paperclip size={13} className="text-edr-muted" />
<Anchor
component="button"
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
onClick={() =>
view({
name: f.name,
url: f.url,
url: fileViewUrl(f.id),
mimeType: f.mimeType,
})
}
size="xs"
style={{
textDecoration:
f.status === "pending_remove"
? "line-through"
: undefined,
}}
>
{f.name}
</Anchor>
{f.status === "pending_add" && (
<Badge size="xs" color="yellow" variant="light">
Pending approval
</Badge>
)}
{f.status === "pending_remove" && (
<Badge size="xs" color="red" variant="light">
Removal pending
</Badge>
)}
</Group>
))}
{(p.licenseFiles ?? []).length === 0 && (
<Text size="xs" c="dimmed">
No license documents.
</Text>
)}
</Stack>
))}
</Stack>
@@ -723,9 +809,9 @@ 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
}
/>
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
message: "Failed to load invoices.",
onRetry: () => void invoicesQuery.refetch(),
}
: undefined
}
pagination={{

View File

@@ -18,14 +18,11 @@ import {
HardDrive,
Layers,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
X,
} from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
const { data, isLoading, isError, error, refetch } = useQuery(
api.fileUploadSettings.list.queryOptions(),
);
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
// Settings are seeded/fixed — staff may only update a setting's fields,
// not create, edit, or delete the settings themselves.
cell: ({ row }) => {
const setting = row.original;
return (
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
Fields
</Button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog mode="edit" setting={setting}>
<ActionIcon variant="default" aria-label="Edit setting">
<Pencil size={16} />
</ActionIcon>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
>
<ActionIcon
variant="default"
color="red"
disabled={deleteMutation.isPending}
aria-label="Delete setting"
>
<Trash2 size={16} />
</ActionIcon>
</DeleteFileUploadSettingDialog>
</Group>
);
},
},
];
}, [deleteMutation]);
}, []);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
<PageHeader
title="File upload settings"
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
action={
<EditFileUploadSettingDialog mode="create">
<Button leftSection={<Plus size={18} />}>New setting</Button>
</EditFileUploadSettingDialog>
}
/>
<KpiStrip
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
emptyMessage={
query.trim()
? "No file upload settings match your search."
: 'No file upload settings yet. Click "New setting" to add one.'
: "No file upload settings configured."
}
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
/>

View File

@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
record={historyTarget}
/>
) : (
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
)}
</Container>
);
};

View File

@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
handoverDocumentReference: row.handoverDocumentReference,
handoverDocumentDate: row.handoverDocumentDate,
deliveredAt: row.deliveredAt,
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
// details captured at arrival (plate, driver, tare, gate-in).
notes: row.notes,
booking: row.bookingId
? {
id: row.bookingId,
@@ -1282,6 +1285,17 @@ const LastMilePage = () => {
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
return (
<Group gap={4} justify="flex-end" wrap="nowrap">
{pastTransit && (
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<Receipt size={13} />}
onClick={() => setDetentionRecord(row.original)}
>
Detention
</Button>
)}
<Menu
position="bottom-end"
width={200}

View File

@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
{ label: "Both", value: "BOTH" },
];
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
const YARD_COUNTRIES = [
{ label: "Ethiopia", value: "Ethiopia" },
{ label: "Djibouti", value: "Djibouti" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
@@ -181,6 +187,7 @@ const CURRENCIES = [
const PRIORITY_CONFIG_TYPES = [
{ label: "Wagon count", value: "WAGON" },
{ label: "Payment currency", value: "CURRENCY" },
{ label: "Customs clearance", value: "CUSTOMS" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
@@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "priority-configs",
label: "Priority Rules",
category: "rules",
subtitle: "Wagon-count and payment-currency scoring rules",
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
searchPlaceholder: "Search priority rules...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
@@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
placeholder: "Select a currency",
hideWhen: { field: "type", equals: ["WAGON"] },
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
},
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
@@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
formFields: [
@@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
@@ -431,7 +436,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{
name: "country",
label: "Country",
type: "select",
required: true,
options: YARD_COUNTRIES,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -33,6 +33,7 @@ import {
RefreshCw,
Ruler,
TrainFront,
Trophy,
Weight,
XCircle,
} from "lucide-react";
@@ -55,6 +56,8 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
api.trainScheduling.batchBoardDetail.queryOptions({
input: { scheduleId: scheduleId ?? "" },
enabled: Boolean(scheduleId),
refetchInterval: 30_000,
// Poll fast while a window cycle is actively moving (open / doc-review /
// payment) so the priority ranking + pay countdowns stay live; back off to
// 30s once the cycle is idle (pre-window / closed / done).
refetchInterval: (query) => {
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
?.windowPhase;
return phase === "OPEN" ||
phase === "DOC_REVIEW" ||
phase === "PAYMENT"
? 5_000
: 30_000;
},
}),
);
// Keep the board in sync with server-pushed window-phase transitions too
// (invalidates the batch-board list + patches window carousels).
useBookingWindowSocket(Boolean(scheduleId));
const runAllocation = useMutation(
api.trainScheduling.runAllocation.mutationOptions(),
);
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab
value="priority"
leftSection={<Trophy size={14} />}
>
Priority Tracking{" "}
{allBookings.length > 0 && `(${allBookings.length})`}
</Tabs.Tab>
<Tabs.Tab value="composition">
Train Composition{" "}
{scheduleDetailQuery.data?.trainSet?.wagons &&
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
</Stack>
</Tabs.Panel>
<Tabs.Panel value="priority" pt="lg">
<PriorityTrackingTab data={data} bookings={allBookings} />
</Tabs.Panel>
<Tabs.Panel value="composition" pt="lg">
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
<Group align="stretch" gap="md" wrap="nowrap">

View File

@@ -16,7 +16,7 @@ import {
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
@@ -26,7 +26,11 @@ import {
import { PageContainer } from "@/components/page";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) {
});
}
/** Compact icon + label + value cell used in the header meta strip. */
function MetaStat({
/**
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
* value, laid on the translucent panel over the gradient.
*/
function HeroStat({
icon,
label,
value,
@@ -63,15 +70,33 @@ function MetaStat({
value: string;
}) {
return (
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="edr-green">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
color: "white",
flexShrink: 0,
}}
>
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
>
{label}
</Text>
<Text size="sm" fw={700} c="dark.5" truncate>
<Text size="sm" fw={700} c="white" truncate>
{value}
</Text>
</Stack>
@@ -79,6 +104,38 @@ function MetaStat({
);
}
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Stack>
</Group>
);
}
const CARD_STYLE = {
borderColor: scheduleBrand.mutedBorder,
boxShadow: scheduleBrand.shadowSm,
} as const;
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
const { toast } = useToast();
@@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() {
const canLog = track.status === "DISPATCHED";
const totalStations = track.stations.length;
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const progressPct =
totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
const clampedPct = Math.min(100, Math.max(0, progressPct));
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const currentStation =
track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
const inTransit = track.status === "DISPATCHED";
const arrived = track.status === "ARRIVED";
const handleLog = (sequenceNo: number) => {
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
@@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() {
Back to schedule
</Button>
{/* Header */}
<Paper radius="lg" withBorder p="lg">
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 48,
height: 48,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: freightBrand.gradient,
color: "white",
flexShrink: 0,
}}
>
<Navigation size={24} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800}>
Train tracking
</Title>
{track.trainNumber ? (
<Badge variant="light" color="edr-green" radius="sm">
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge variant="light" color="gray" radius="sm">
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={360}>
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper
radius="lg"
p={0}
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
>
<Box
style={{
background: scheduleBrand.heroGradient,
padding: "26px 28px",
position: "relative",
}}
>
{/* soft decorative glow, purely artistic */}
<Box
style={{
position: "absolute",
top: -80,
right: -60,
width: 260,
height: 260,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="xl"
style={{ position: "relative" }}
>
{/* left — identity + route */}
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
<Group gap="md" align="center" wrap="nowrap">
<Box
style={{
width: 52,
height: 52,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.26)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={26} />
</Box>
</Stack>
</Group>
<StatusPill status={track.status} />
</Group>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800} c="white">
Train tracking
</Title>
{track.trainNumber ? (
<Badge
variant="white"
color="dark"
radius="sm"
styles={{ root: { color: freightBrand.primaryDark } }}
>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge
variant="outline"
radius="sm"
styles={{
root: {
color: "white",
borderColor: "rgba(255,255,255,0.5)",
},
}}
>
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={380}>
<RouteCorridor
origin={track.origin}
destination={track.destination}
variant="compact"
onDark
/>
</Box>
</Stack>
</Group>
{/* Journey progress */}
<Box>
<Group justify="space-between" mb={6}>
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
Journey progress
</Text>
<Text size="xs" fw={700} c="edr-green.8">
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
</Text>
</Group>
<Progress
value={clampedPct}
size="lg"
radius="xl"
color="edr-green"
striped={track.status === "DISPATCHED"}
animated={track.status === "DISPATCHED"}
/>
</Box>
<Group gap="sm">
<StatusPill status={track.status} size="md" />
<Box
px={12}
py={5}
style={{
borderRadius: 999,
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
}}
>
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
{arrived
? "Journey complete"
: inTransit
? `En route · ${currentStation}`
: "Awaiting dispatch"}
</Text>
</Box>
</Group>
</Stack>
{/* Meta strip */}
<Group justify="space-between" wrap="wrap" gap="lg">
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<MetaStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<MetaStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<MetaStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
{/* right — progress ring, the artistic focal point */}
<RingProgress
size={132}
thickness={11}
roundCaps
sections={[{ value: clampedPct, color: "white" }]}
rootColor="rgba(255,255,255,0.22)"
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1} c="white">
{Math.round(clampedPct)}%
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/>
</Group>
</Stack>
</Box>
{/* glass meta strip below the wash */}
<Group
justify="space-between"
wrap="wrap"
gap="lg"
px={28}
py="md"
style={{
background: freightBrand.primaryDark,
borderTop: "1px solid rgba(255,255,255,0.12)",
}}
>
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<HeroStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<HeroStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<HeroStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Paper>
{/* Corridor */}
<Paper radius="lg" p="lg" withBorder>
{/* ── Route corridor ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Stack gap="md">
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<Navigation size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Route corridor
</Text>
<Text size="xs" c="dimmed">
{canLog
? "Log the train passing each station; the final station marks arrival."
: track.status === "ARRIVED"
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."}
</Text>
</Stack>
</Group>
<SectionHead
icon={<Navigation size={17} />}
title="Route corridor"
hint={
canLog
? "Log the train passing each station; the final station marks arrival."
: arrived
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."
}
/>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
/>
</Stack>
</Paper>
{/* Checkpoint log */}
<Paper radius="lg" p="lg" withBorder>
<Group gap="sm" align="center" wrap="nowrap" mb="md">
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
<CheckCircle2 size={17} />
</ThemeIcon>
<Stack gap={0}>
<Text fw={800} size="sm">
Checkpoint log
</Text>
<Text size="xs" c="dimmed">
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
</Text>
</Stack>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">
<SectionHead
icon={<CheckCircle2 size={17} />}
title="Checkpoint log"
hint={`${track.checkpoints.length} event${
track.checkpoints.length === 1 ? "" : "s"
} recorded`}
/>
</Group>
{track.checkpoints.length === 0 ? (
<Stack align="center" gap="xs" py="xl">
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
<MapPin size={20} />
<Stack
align="center"
gap="xs"
py={40}
style={{
borderRadius: 14,
border: `1px dashed ${scheduleBrand.mutedBorder}`,
background: scheduleBrand.softSurface,
}}
>
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
<MapPin size={22} />
</ThemeIcon>
<Text size="sm" fw={600} c="gray.7">
<Text size="sm" fw={700} c="gray.7">
No checkpoints yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={300}>
Each station the train passes will be logged here with its timestamp.
<Text size="xs" c="dimmed" ta="center" maw={320}>
Each station the train passes will be logged here with its
timestamp.
</Text>
</Stack>
) : (
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green">
<Timeline
active={track.checkpoints.length}
bulletSize={24}
lineWidth={2}
color="edr-green"
>
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
bullet={
cp.kind === "ARRIVED" ? (
<CheckCircle2 size={13} />
) : (
<MapPin size={12} />
)
}
title={
<Group gap="sm">
<Text fw={700} size="sm">
@@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() {
size="xs"
radius="sm"
variant="light"
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"}
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>

View File

@@ -49,6 +49,7 @@ import {
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -863,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
</ThemeIcon>
<Stack gap={6}>
<Group gap="sm" align="center" wrap="wrap">
{schedule.reference ? (
<Badge
variant="filled"
color="edr-green"
radius="sm"
style={{ fontWeight: 700, fontFamily: "monospace" }}
>
{schedule.reference}
</Badge>
) : null}
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
{schedule.route?.name ?? "Train schedule"}
</Title>
@@ -1131,6 +1142,7 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch();
}}
/>
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}

View File

@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const [freightFilter, setFreightFilter] = useState("ALL");
const [originFilter, setOriginFilter] = useState("ALL");
const [destinationFilter, setDestinationFilter] = useState("ALL");
// Default: newest-created first, matching the API's default order.
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
"createdAt",
);
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
const [editDateSchedule, setEditDateSchedule] =
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
return base;
}, [allSchedules]);
// Distinct origins/destinations present in the loaded schedules, for the
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
const originOptions = useMemo(
() =>
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
[allSchedules],
);
const destinationOptions = useMemo(
() =>
[
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
].sort() as string[],
[allSchedules],
);
const filtered = useMemo(() => {
const query = search.trim().toLowerCase();
return allSchedules.filter((s) => {
const matched = allSchedules.filter((s) => {
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
return false;
if (!query) return true;
const haystack = [
s.reference,
s.trainNumber,
s.routeName,
s.origin,
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
.toLowerCase();
return haystack.includes(query);
});
}, [allSchedules, search, statusFilter, freightFilter]);
const dir = sortDir === "asc" ? 1 : -1;
const sorted = [...matched].sort((a, b) => {
let cmp = 0;
if (sortBy === "reference") {
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
} else {
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
const av = new Date(a[sortBy] ?? 0).getTime();
const bv = new Date(b[sortBy] ?? 0).getTime();
cmp = av - bv;
}
return cmp * dir;
});
return sorted;
}, [
allSchedules,
search,
statusFilter,
freightFilter,
originFilter,
destinationFilter,
sortBy,
sortDir,
]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
const paged = useMemo(() => {
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "reference",
header: "Ref",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
{row.original.reference ?? "—"}
</Text>
),
},
{
id: "date",
header: "Departure",
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
w={140}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Origin"
searchable
value={originFilter}
onChange={(v) => setOriginFilter(v ?? "ALL")}
data={[
{ value: "ALL", label: "All origins" },
...originOptions.map((o) => ({ value: o, label: o })),
]}
w={160}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
placeholder="Destination"
searchable
value={destinationFilter}
onChange={(v) => setDestinationFilter(v ?? "ALL")}
data={[
{ value: "ALL", label: "All destinations" },
...destinationOptions.map((d) => ({ value: d, label: d })),
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
<Select
size="sm"
radius="lg"
value={`${sortBy}:${sortDir}`}
onChange={(v) => {
if (!v) return;
const [by, dir] = v.split(":") as [
typeof sortBy,
typeof sortDir,
];
setSortBy(by);
setSortDir(dir);
}}
data={[
{ value: "createdAt:desc", label: "Newest created" },
{ value: "createdAt:asc", label: "Oldest created" },
{ value: "scheduleDate:desc", label: "Departure ↓" },
{ value: "scheduleDate:asc", label: "Departure ↑" },
{ value: "reference:asc", label: "Reference ↑" },
{ value: "reference:desc", label: "Reference ↓" },
]}
w={170}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
/>
</>
}
/>
@@ -693,9 +805,16 @@ function ScheduleCard({
<Train size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
<Group gap={6} wrap="nowrap">
{schedule.reference ? (
<Text size="xs" fw={700} ff="monospace" c="edr-green.8">
{schedule.reference}
</Text>
) : null}
<Text fw={600} size="sm" lineClamp={1}>
{schedule.routeName ?? "Train schedule"}
</Text>
</Group>
<Text size="xs" c="dimmed">
{day} · {time}
</Text>

View File

@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
},
{
id: 'weight',
header: 'Loaded Weight (kg)',
header: 'Loaded Weight (t)',
cell: ({ row }) => formatNumber(row.original.loadedWeight),
},
{

View File

@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
},
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
{
id: 'payment',
header: 'Payment',

View File

@@ -16,7 +16,7 @@ import {
Text,
TextInput,
} from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react';
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@@ -30,6 +30,8 @@ import {
useDeleteAllocationRule,
useDeleteFeeRule,
useFeeRules,
useUpdateAllocationRule,
useUpdateFeeRule,
} from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import {
@@ -37,6 +39,9 @@ import {
FEE_RULE_BASIS_LABELS,
FEE_RULE_TYPES,
FEE_RULE_TYPE_LABELS,
VEHICLE_TYPES,
type AllocationRule,
type FeeRule,
type FeeRuleBasis,
type FeeRuleType,
} from '@/types/warehouse';
@@ -120,8 +125,10 @@ function AllocationRules() {
const { data, isLoading } = useAllocationRules();
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
const create = useCreateAllocationRule();
const update = useUpdateAllocationRule();
const remove = useDeleteAllocationRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
priority: 100,
@@ -141,7 +148,8 @@ function AllocationRules() {
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
}));
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
priority: 100,
@@ -152,6 +160,22 @@ function AllocationRules() {
targetYardCode: '',
storageType: '',
});
};
const startEdit = (rule: AllocationRule) => {
setForm({
name: rule.name,
priority: rule.priority ?? 100,
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerStatus: rule.containerStatus ?? '',
targetYardCode: rule.targetYardCode ?? '',
storageType: rule.storageType ?? '',
});
setEditingId(rule.id);
setOpen(true);
};
const submit = async () => {
if (!form.name.trim() || !form.targetYardCode.trim()) {
@@ -159,7 +183,7 @@ function AllocationRules() {
return;
}
await create.mutateAsync({
const payload = {
name: form.name.trim(),
priority: form.priority,
freightType: clean(form.freightType) ?? null,
@@ -169,10 +193,20 @@ function AllocationRules() {
targetYardCode: form.targetYardCode.trim(),
storageType: clean(form.storageType) ?? null,
isActive: true,
} as never);
toast({ title: 'Allocation rule created' });
setOpen(false);
resetForm();
};
try {
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Allocation rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Allocation rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
return (
@@ -181,7 +215,7 @@ function AllocationRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) matched by ascending priority
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New allocation rule
</Button>
</Group>
@@ -228,14 +262,19 @@ function AllocationRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -244,7 +283,7 @@ function AllocationRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
<Stack gap="md">
<Card withBorder radius="md" padding="sm" bg="gray.0">
<Stack gap={4}>
@@ -339,11 +378,11 @@ function AllocationRules() {
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>
@@ -362,8 +401,10 @@ function FeeRules() {
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule();
const update = useUpdateFeeRule();
const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({
name: '',
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
@@ -372,6 +413,7 @@ function FeeRules() {
tradeDirection: '',
cargoTypeCode: '',
containerType: '',
vehicleType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
@@ -389,8 +431,11 @@ function FeeRules() {
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
// with day tiers. Uses "free hours" instead of "free days".
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
const isImportOnly = isDoubleHandling || isTruckDetention;
const resetForm = () =>
const resetForm = () => {
setEditingId(null);
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
@@ -399,12 +444,34 @@ function FeeRules() {
tradeDirection: '',
cargoTypeCode: '',
containerType: '',
vehicleType: '',
freeDays: 3,
freeHours: 3,
ratePerDay: 0,
tiers: [],
currency: 'USD',
});
};
const startEdit = (rule: FeeRule) => {
setForm({
name: rule.name,
ruleType: rule.ruleType,
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
freightType: rule.freightType ?? '',
tradeDirection: rule.tradeDirection ?? '',
cargoTypeCode: rule.cargoTypeCode ?? '',
containerType: rule.containerType ?? '',
vehicleType: rule.vehicleType ?? '',
freeDays: rule.freeDays ?? 3,
freeHours: rule.freeHours ?? 3,
ratePerDay: rule.ratePerDay ?? 0,
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
currency: rule.currency ?? 'USD',
});
setEditingId(rule.id);
setOpen(true);
};
const addTier = () =>
setForm((f) => {
@@ -461,7 +528,7 @@ function FeeRules() {
name: form.name.trim(),
ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null,
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
@@ -469,17 +536,22 @@ function FeeRules() {
ratePerDay: form.ratePerDay,
currency: form.currency || 'USD',
...(isDoubleHandling ? { basis: form.basis } : {}),
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
};
try {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
if (editingId) {
await update.mutateAsync({ id: editingId, payload: payload as never });
toast({ title: 'Fee rule updated' });
} else {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
}
setOpen(false);
resetForm();
} catch (error) {
if (tiers.length && isUnknownTiersError(error)) {
if (!editingId && tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit<typeof payload, 'tiers'> = {
name: payload.name,
ruleType: payload.ruleType,
@@ -500,7 +572,7 @@ function FeeRules() {
resetForm();
return;
}
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
}
};
@@ -510,7 +582,7 @@ function FeeRules() {
<Text c="dimmed" size="sm">
{rules.length} rule(s) - most specific match applies
</Text>
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
New fee rule
</Button>
</Group>
@@ -572,14 +644,19 @@ function FeeRules() {
</Badge>
</Table.Td>
<Table.Td ta="right">
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
<Pencil size={16} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => remove.mutate(rule.id)}
title="Delete"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
@@ -588,7 +665,7 @@ function FeeRules() {
</Table.ScrollContainer>
)}
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
<Stack gap="sm">
<Group grow>
<TextInput
@@ -633,11 +710,23 @@ function FeeRules() {
/>
<Select
label="Trade direction"
description={isImportOnly ? 'Import only for this fee type' : undefined}
data={TRADE}
value={form.tradeDirection || null}
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
clearable
disabled={isImportOnly}
clearable={!isImportOnly}
/>
{isTruckDetention && (
<Select
label="Truck type"
placeholder="Any truck type"
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
value={form.vehicleType || null}
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
clearable
/>
)}
{isBulkRule && (
<Select
label="Cargo type"
@@ -758,11 +847,11 @@ function FeeRules() {
</Stack>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}>
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
Cancel
</Button>
<Button loading={create.isPending} onClick={submit}>
Create
<Button loading={create.isPending || update.isPending} onClick={submit}>
{editingId ? 'Save changes' : 'Create'}
</Button>
</Group>
</Stack>

View File

@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingDetail } from "@/types/booking";
import type {
Company,
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -99,6 +100,7 @@ import type {
LoadInventoryPayload,
LoadPassedExportResult,
MoveInventoryPayload,
StoreInventoryPayload,
PayInvoicePayload,
ReadyToLoadRow,
ReceiveInventoryPayload,
@@ -180,6 +182,7 @@ import {
wagonService,
type Wagon,
type WagonListFilters,
type WagonMovementRecord,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -593,6 +596,40 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
yardWork: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").YardWorkResult
>(
"train-scheduling",
"yard-work",
({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId),
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
),
loadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingLoadResult
>(
"train-scheduling",
"booking-load",
({ scheduleId, bookingId }) =>
trainSchedulingService.loadScheduleBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingUnloadResult
>(
"train-scheduling",
"booking-unload",
({ scheduleId, bookingId }) =>
trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
@@ -1016,10 +1053,13 @@ export const api = {
() => [["warehouse-inventory"], ["warehouses"]],
),
store: endpoint<string, WarehouseInventoryItem>(
store: endpoint<
{ id: string; payload?: StoreInventoryPayload },
WarehouseInventoryItem
>(
"warehouse-inventory",
"store",
(id) => warehouseService.store(id).then((r) => r.data),
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
undefined,
() => INVENTORY_INVALIDATIONS,
),
@@ -1493,6 +1533,13 @@ export const api = {
wagonService.getById(id).then((r) => r.data),
),
movements: endpoint<{ id: string }, WagonMovementRecord[]>(
"wagons",
"movements",
({ id }) => wagonService.getMovements(id).then((r) => r.data),
({ id }) => ["wagons", "movements", id],
),
assignToTrain: endpoint<
{ wagonId: string; trainId: string; sequenceNumber?: number },
Wagon
@@ -2219,13 +2266,13 @@ export const api = {
),
setProfileStatus: endpoint<
{ profileId: string; status: ProfileStatus },
{ profileId: string; status: ProfileStatus; note?: string },
CompanyProfile
>(
"customers",
"setProfileStatus",
({ profileId, status }) =>
customersService.setProfileStatus(profileId, status),
({ profileId, status, note }) =>
customersService.setProfileStatus(profileId, status, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
@@ -2233,6 +2280,37 @@ export const api = {
],
),
changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
"customers",
"changeRequests",
({ id }) => customersService.changeRequests(id),
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
),
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
"customers",
"approveChangeRequest",
({ id }) => customersService.approveChangeRequest(id),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
"customers",
"rejectChangeRequest",
({ id, note }) => customersService.rejectChangeRequest(id, note),
undefined,
(_input, data) => [
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
QUERY_KEYS.CUSTOMERS.ROOT,
],
),
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
"customers",
"setCompanyStatus",

View File

@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
import { URL_CONSTANTS } from "@/constants/URLS";
import type {
Company,
CompanyChangeRequest,
CompanyListFilter,
CompanyProfile,
CompanyStats,
@@ -80,11 +81,15 @@ export const customersService = {
.then((r) => r.data);
},
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
setProfileStatus(
profileId: string,
status: ProfileStatus,
note?: string,
): Promise<CompanyProfile> {
return apiClient
.patch<CompanyProfile>(
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
{ status },
{ status, note },
)
.then((r) => r.data);
},
@@ -95,4 +100,32 @@ export const customersService = {
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
.then((r) => r.data);
},
/** List a company's profile-edit change requests (newest first). */
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
return apiClient
.get<CompanyChangeRequest[]>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
)
.then((r) => r.data);
},
/** Approve a pending change request — applies the proposed changes. */
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
)
.then((r) => r.data);
},
/** Reject a pending change request with a note. */
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
return apiClient
.post<CompanyChangeRequest>(
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
{ note },
)
.then((r) => r.data);
},
};

View File

@@ -8,6 +8,8 @@ import type {
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
BookingLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
@@ -35,6 +37,7 @@ import type {
UploadImportDjiboutiDocumentPayload,
WagonAllocationAttemptResult,
YardOption,
YardWorkResult,
} from "@/types/trainScheduling";
interface BookingReferenceDataResponse {
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getYardWork: async (scheduleId: string): Promise<YardWorkResult> => {
const response = await client.get<YardWorkResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId),
);
return unwrap(response.data);
},
loadScheduleBooking: async (
scheduleId: string,
bookingId: string,
): Promise<BookingLoadResult> => {
const response = await client.post<BookingLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId),
{},
);
return unwrap(response.data);
},
unloadScheduleBooking: async (
scheduleId: string,
bookingId: string,
): Promise<BookingUnloadResult> => {
const response = await client.post<BookingUnloadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId),
{},
);
return unwrap(response.data);
},
getIntercityCandidates: async (
scheduleId: string,
): Promise<IntercityCandidatesResult> => {

View File

@@ -37,6 +37,27 @@ export interface WagonListFilters {
trainId?: string;
}
/**
* One row of the wagon_movements ledger: every physical relocation between
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
* correction. Returned newest first by the API.
*/
export interface WagonMovementRecord {
id: string;
wagonId: string;
fromYardId: string | null;
toYardId: string;
fromYard?: { id?: string; label?: string; code?: string } | null;
toYard?: { id?: string; label?: string; code?: string } | null;
trainScheduleId: string | null;
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
}
export const wagonService = {
getAll: (filters: WagonListFilters = {}) => {
const params = new URLSearchParams();
@@ -49,6 +70,8 @@ export const wagonService = {
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
},
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),

View File

@@ -30,6 +30,7 @@ import type {
LoadableWagon,
LoadInventoryPayload,
MoveInventoryPayload,
StoreInventoryPayload,
ReceiveInventoryPayload,
ReleaseOrderPayload,
DeliverInventoryPayload,
@@ -63,7 +64,7 @@ import type {
WarehouseZone,
} from '@/types/warehouse';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
export interface ContainerItem {
containerNumber: string;
@@ -74,9 +75,12 @@ export interface ContainerItem {
truckPlate: string | null;
truckArrived: boolean;
truckLeft: boolean;
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
loaded: boolean;
bookingReference: string | null;
contractId: string | null;
hasLastMile: boolean;
handoverSigned: boolean;
}
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
@@ -135,6 +139,26 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
requestHandoverSignature: async (
bookingId: string,
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
const { data } = await apiClient.post(
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
);
return data?.data ?? data;
},
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
getContainerWeights: async (
bookingId: string,
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
const { data } = await apiClient.get(
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
);
return data?.data ?? data ?? [];
},
/** Booking container numbers not yet loaded onto any truck. */
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
const { data } = await apiClient.get(
@@ -235,8 +259,8 @@ export const warehouseService = {
}),
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
store: (id: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
store: (id: string, payload?: StoreInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
reserve: (payload: ReserveInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
markReadyForLoading: (id: string) =>

View File

@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"REJECTED",
"CANCELLED",
@@ -206,7 +207,7 @@ export interface BookingDetail {
company?: BookingNamedRef & Partial<BookingCompany>;
originYard?: BookingNamedRef;
destinationYard?: BookingNamedRef;
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
@@ -238,7 +239,6 @@ export interface BookingListRow {
priorityScore: number;
schedulingStatus?: string;
serviceTypeLabel?: string;
serviceTypeBonus?: number;
trainScheduleId?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;

View File

@@ -30,14 +30,24 @@ export type ProfileType =
| "transporter";
/** Mirrors backend `ProfileStatus`. */
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
export type ProfileStatus =
| "active"
| "pending"
| "rejected"
| "suspended"
| "blacklisted";
/** A business-license document uploaded for a company profile. */
/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
/** A business-license document uploaded for a company profile (FileRecord-backed). */
export interface LicenseFile {
id: string;
name: string;
url: string;
size: number;
mimeType?: string;
mimeType: string;
/** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
status: LicenseFileStatus;
}
/** A single role a company is registered for, with its reference code. */
@@ -52,6 +62,39 @@ export interface CompanyProfile {
/** Business-license documents uploaded for this profile. */
licenseFiles?: LicenseFile[];
attributes?: Record<string, unknown> | null;
/** Reviewer note when the role is rejected. */
reviewNote?: string | null;
createdAt: string;
updatedAt: string;
}
/** Lifecycle of a staged customer profile-edit review. */
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
/** A staged business-license add/remove on one profile, awaiting review. */
export interface LicenseChangeIntent {
profileId: string;
op: "add" | "remove";
fileId: string;
fileName?: string;
}
/**
* A staged profile-edit change request. The customer's settings edits land here
* (pending) until a reviewer approves (applies them) or rejects (with a note).
*/
export interface CompanyChangeRequest {
id: string;
companyId: string;
status: ChangeRequestStatus;
/** Proposed field values (the diff payload vs. the live company). */
snapshot: Record<string, unknown>;
documentFileIds: string[];
/** Staged business-license add/remove intents attached to this request. */
licenseChanges: LicenseChangeIntent[];
note: string | null;
submittedAt: string | null;
reviewedAt: string | null;
createdAt: string;
updatedAt: string;
}
@@ -118,6 +161,7 @@ export type CustomerBookingStatus =
| "APPROVED"
| "PAID"
| "IN_TRANSIT"
| "ARRIVED"
| "COMPLETED"
| "REJECTED"
| "CANCELLED";

View File

@@ -152,6 +152,8 @@ export interface LocomotiveRecord {
export interface TrainScheduleListItem {
id: string;
reference?: string | null;
createdAt?: string | null;
scheduleDate: string;
trainNumber?: string | null;
routeName?: string | null;
@@ -225,6 +227,10 @@ export interface BatchBoardBooking {
lengthMeters: number;
paymentDeadline: string | null;
state: BatchBoardBookingState;
/** Rule-engine priority score used to rank the batch (higher = boards first). */
priorityScore: number;
/** CONTAINER | BULK — for the priority-tracking visuals. */
freightType: string | null;
}
/**
@@ -233,6 +239,7 @@ export interface BatchBoardBooking {
*/
export interface StaffBookingWindow {
scheduleId: string;
reference: string | null;
trainNumber: string | null;
direction: "IMPORT" | "EXPORT" | null;
windowPhase: BookingWindowPhase | string | null;
@@ -276,6 +283,8 @@ export interface BatchBoardSchedule {
maxLengthMeters: number | null;
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
};
counts: {
allocated: number;
@@ -359,6 +368,8 @@ export interface BookingWindow {
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
@@ -426,6 +437,7 @@ export interface UpdateScheduleWindowRulePayload {
export interface TrainScheduleDetail {
id: string;
reference?: string | null;
status: TrainScheduleStatus | string;
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
@@ -794,3 +806,52 @@ export interface IntercityAcceptResult {
rejected: Array<{ bookingId: string; reason: string }>;
remaining: IntercityCapacity;
}
// ── Yard load / unload worklist ──────────────────────────────────────────────
// Per-booking journey along the train's corridor: every booking boards at its
// origin yard and alights at its destination yard, confirmed by the yard
// operator while the train's latest checkpoint is at that yard.
export interface YardWorkBookingRow {
id: string;
reference: string | null;
status: string;
tradeDirection: string;
isGovernment: boolean;
customer: string;
originYardId: string;
destinationYardId: string;
origin: string;
destination: string;
loadedAt: string | null;
arrivedAt: string | null;
canLoad: boolean;
canUnload: boolean;
}
export interface YardWorkYard {
yardId: string;
yard: string;
toLoad: YardWorkBookingRow[];
toUnload: YardWorkBookingRow[];
}
export interface YardWorkResult {
scheduleId: string;
scheduleStatus: string;
trainAtYardId: string | null;
yards: YardWorkYard[];
}
export interface BookingLoadResult {
bookingId: string;
status: string;
loadedAt: string;
}
export interface BookingUnloadResult {
bookingId: string;
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
status: string;
arrivedAt: string;
}

View File

@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
export type InventoryAction =
| 'store'
| 'reserve'
| 'ready-for-loading'
| 'load'
| 'dispatch'
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
UNLOADED: 'store',
UNLOADED_AT_DJIBOUTI_PORT: null,
RECEIVED: 'store',
STORED: 'reserve',
STORED: 'ready-for-loading',
RESERVED: 'ready-for-loading',
ARRIVED_AT_WAREHOUSE: null,
UNDER_INSPECTION: null,
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
// Import goods skip storage; they need inspection before pickup.
if (isImport) return inspected ? 'ready-for-pickup' : null;
return 'store';
case 'STORED':
// Reserve is retired: a stored export item goes straight to loading prep
// once inspection passes. Import STORED is handled via the import queue.
if (isImport) return null;
return inspected ? 'ready-for-loading' : null;
case 'RESERVED':
// Export loading is gated on a passed inspection.
return inspected ? 'ready-for-loading' : null;
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
hasAssignedTruck: boolean;
currentStatus: string;
releaseDate: string | null;
releaseOrderReference: string | null;
handoverDocumentReference: string | null;
handoverDocumentDate: string | null;
deliveredAt: string | null;
notes: string | null;
}
/** Optional explicit storage location; blank → backend auto-allocates. */
export interface StoreInventoryPayload {
warehouseId?: string;
yardId?: string;
zoneId?: string;
}
export interface ImportTrainItem {
@@ -763,6 +776,10 @@ export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
PER_ITEM: 'Per Item',
};
/** Vehicle types a Truck Detention rule can be scoped to (rates differ by truck type). */
export const VEHICLE_TYPES = ['TRUCK', 'VAN', 'CAR', 'BUS', 'TRAILER', 'TANKER', 'FLATBED'] as const;
export type VehicleTypeCode = (typeof VEHICLE_TYPES)[number];
export interface FeeRule {
id: string;
name: string;
@@ -774,6 +791,8 @@ export interface FeeRule {
tradeDirection?: string | null;
cargoTypeCode?: string | null;
containerType?: string | null;
/** Truck detention only: scope by vehicle type (null = any). */
vehicleType?: string | null;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
@@ -820,6 +839,16 @@ export interface FeePreview {
billableUnits: number;
amount: number;
tiers?: FeePreviewTier[];
/** Truck detention: per-vehicle-type breakdown. */
groups?: Array<{
vehicleType: string | null;
truckCount: number;
chargeableDays: number;
ratePerDay: number;
amount: number;
ruleId: string | null;
ruleName: string | null;
}>;
}
export interface AllocationPreviewResult {

View File

@@ -8,6 +8,32 @@ export type ApiError = {
statusCode?: number;
};
/**
* Backend errors arrive as snake_case i18n-style codes (e.g.
* "unable_to_log_in"). Map the known ones to friendly copy and prettify
* anything else so raw codes never reach the UI. `code` stays raw for
* programmatic checks.
*/
const API_ERROR_MESSAGES: Record<string, string> = {
unable_to_log_in: "Incorrect email or password.",
invalid_refresh_token: "Your session has expired. Please sign in again.",
session_expired: "Your session has expired. Please sign in again.",
session_not_found: "Your session has expired. Please sign in again.",
user_not_found: "No account found for these credentials.",
};
const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
function humanizeApiMessage(raw: string): string {
const known = API_ERROR_MESSAGES[raw];
if (known) return known;
if (SNAKE_CASE_CODE.test(raw)) {
const text = raw.replaceAll("_", " ");
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
}
return raw;
}
export function extractApiError(err: unknown): ApiError {
if (err && typeof err === "object") {
const obj = err as Record<string, unknown>;
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
const statusCode = response.status as number | undefined;
const data = response.data as Record<string, unknown> | undefined;
return {
code: (data?.error as string) || (data?.message as string) || "api_error",
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
code: (data?.message as string) || (data?.error as string) || "api_error",
message: humanizeApiMessage(
(data?.message as string) ||
(data?.error as string) ||
"An unexpected error occurred",
),
statusCode,
};
}