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

This commit is contained in:
Nathnael
2026-07-22 07:20:08 +00:00
195 changed files with 9081 additions and 2516 deletions

View File

@@ -151,6 +151,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Customers",
href: "/dashboard/customers",
icon: <Building2 />,
permission: FREIGHT_PERMS.customers.view,
},
{
label: "Contracts",
@@ -162,6 +163,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Bookings",
href: "/dashboard/booking-requests",
icon: <FileText />,
permission: FREIGHT_PERMS.bookings.view,
},
// Operations hub: clearance-document review for contracts WITHOUT
// customs clearing (contract-level for one-time, per-booking for general).
@@ -375,6 +377,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Imports",
href: "/dashboard/import-warehouse",
icon: <PackageOpen />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Import Overview",
@@ -407,6 +410,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Exports",
href: "/dashboard/export-warehouse",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
children: [
{
label: "Export Overview",
@@ -449,6 +453,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Intercity",
href: "/dashboard/intercity",
icon: <TrainFront />,
permission: FREIGHT_PERMS.trainScheduling.view,
children: [
{
label: "Intercity Cargo",
@@ -465,6 +470,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Warehouse Dashboard",
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
permission: FREIGHT_PERMS.warehouseDashboard.view,
},
{
// Yard-wide, not per-direction: the gate sees import and export
@@ -472,21 +478,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
icon: <Container />,
permission: FREIGHT_PERMS.warehouses.view,
},
{
label: "Allocation & Fees",
href: "/dashboard/warehouse-rules",
icon: <SlidersHorizontal />,
permission: FREIGHT_PERMS.warehouseAllocationRules.view,
},
{
label: "Fee Invoices",
href: "/dashboard/warehouse-fee-invoices",
icon: <Wallet />,
permission: FREIGHT_PERMS.warehouseFeeInvoices.view,
},
],
},
@@ -523,10 +533,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
label: "Contract validity",
href: "/dashboard/configuration/contract-validity-periods",
permission: FREIGHT_PERMS.config.contractValidity.view,
},
{
label: "Train scheduling rules",
href: "/dashboard/configuration/train-scheduling-rules",
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
},
],
},
@@ -541,6 +553,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
label: "Staff",
href: "/user-management",
icon: <Users />,
permission: FREIGHT_PERMS.admin,
},
],
},
@@ -596,10 +609,22 @@ const filterSidebarByPermission = (
return permissionAllowed(item);
};
// Recursive: a group's own permission gates the whole subtree, leaves are
// checked individually, and a group with no surviving children disappears.
const filterItems = (items: SidebarItem[]): SidebarItem[] =>
items.flatMap((item) => {
if (item.children?.length) {
if (item.permission && !permissionAllowed(item)) return [];
const children = filterItems(item.children);
return children.length ? [{ ...item, children }] : [];
}
return itemAllowed(item) ? [item] : [];
});
return sections
.map((section) => ({
...section,
items: section.items.filter(itemAllowed),
items: filterItems(section.items),
}))
.filter((section) => section.items.length > 0);
};
@@ -708,7 +733,7 @@ const App = () => {
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/fayda/callback" element={<FaydaCallbackPage />} />
<Route path="*" element={<Navigate to="/auth" replace />} />
</Routes>
);

View File

@@ -1,4 +1,5 @@
import axios from "axios";
import toast from "react-hot-toast";
import { API_BASE_URL } from "@/constants/apiConfig";
import { captureApiError } from "@/lib/posthog";
@@ -10,14 +11,16 @@ import {
setCookie,
} from "./cookies";
import type { AuthTokens } from "./types";
import { extractApiErrorPayload } from "@/components/errors/ApiErrorModal";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
* When true, the response interceptor does NOT raise the global error
* toast for this request's failure. For calls the caller handles itself —
* e.g. a probe that is expected to 404 before falling back (GL clearance
* detail tries /contracts/:id then /bookings/:id). The rejection still
* propagates.
*/
suppressErrorModal?: boolean;
}
@@ -92,9 +95,8 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config as RetriableRequest | undefined;
// Report the failure to PostHog. Hooked here rather than inside
// `emitApiError`, which stays silent on suppressed paths (warehouse /
// mile / onboarding) — those failures still need reporting.
// Report the failure to PostHog, including on suppressErrorModal paths —
// those opt out of the user-facing toast, not of reporting.
// 401s are skipped: an expired session is refreshed below, not a defect.
if (!error.response || error.response.status !== 401) {
captureApiError(error);
@@ -108,16 +110,25 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/mfa-verify") ||
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
// const payload = extractApiErrorPayload(error);
// if (payload) emitApiError(payload);
// Surface the server's actual error message in a global toast — never
// the error modal (401s are handled by the session-refresh flow, so skip
// them). A request may opt out via `suppressErrorModal` when it handles
// the failure itself.
if (error.response && error.response.status !== 401) {
const payload = extractApiErrorPayload(error);
// Normalize the error's own `message` to the SERVER's actual message so
// every downstream `toast.error(err.message)` handler shows the real
// cause instead of "Request failed with status code NNN". Applies even
// on suppressErrorModal paths — only the toast is opted out.
if (payload?.messages.length) {
const message = payload.messages.join("\n");
(error as { message?: string }).message = message;
// Keyed by message so a retried request replaces its toast instead
// of stacking duplicates.
if (!originalRequest?.suppressErrorModal) {
toast.error(message, { id: message });
}
}
}
return Promise.reject(error);
}

View File

@@ -23,6 +23,7 @@ interface AuthEmployeePosition {
permissions?: AuthPermission[];
/** Some IAM payloads nest the position record instead of flattening its key. */
position?: { id?: string; key?: string; name?: LocaleText };
positionType?: { id?: string; key?: string; name?: LocaleText } | null;
}
interface AuthEmployeeRecord {

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import { Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -8,6 +8,7 @@ import {
Button,
Box,
Modal,
Select,
Textarea,
} from "@mantine/core";
import type { Freight } from "@edr/types";
@@ -35,6 +36,10 @@ export function ContractApprovalStepsCard({
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
const [rejectReason, setRejectReason] = useState("");
// Where the rejection lands: "CUSTOMER" (terminal, resubmit) or the id of an
// earlier APPROVED step to send the chain back to. First approver has no
// choice — customer only.
const [rejectTarget, setRejectTarget] = useState<string>("CUSTOMER");
const steps = useMemo(
() =>
@@ -46,6 +51,11 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// The card also renders read-only trails (e.g. a REJECTED contract) — only
// offer approve/reject while the backend accepts step actions.
const actionable =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
@@ -70,6 +80,7 @@ export function ContractApprovalStepsCard({
const openReject = (step: Freight.IContractApprovalStep) => {
setRejectStepRow(step);
setRejectReason("");
setRejectTarget("CUSTOMER");
setRejectOpen(true);
};
@@ -77,14 +88,34 @@ export function ContractApprovalStepsCard({
setRejectOpen(false);
setRejectStepRow(null);
setRejectReason("");
setRejectTarget("CUSTOMER");
};
const trimmedReason = rejectReason.trim();
// Earlier stages this rejection can be returned to — only stages that have
// already approved. Empty for the first approver, whose only target is the
// customer.
const returnableSteps = rejectStepRow
? steps.filter(
(s) =>
s.stepOrder < rejectStepRow.stepOrder && s.status === "APPROVED",
)
: [];
const sendBack = rejectTarget !== "CUSTOMER";
const targetStep = sendBack
? returnableSteps.find((s) => s.id === rejectTarget)
: undefined;
const runReject = () => {
if (!rejectStepRow || !trimmedReason) return;
mutations.rejectStep.mutate(
{ stepId: rejectStepRow.id, reason: trimmedReason },
{
stepId: rejectStepRow.id,
reason: trimmedReason,
returnToStepId: sendBack ? rejectTarget : undefined,
},
{ onSuccess: () => closeReject() },
);
};
@@ -134,7 +165,7 @@ export function ContractApprovalStepsCard({
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isNext={actionable && nextPending?.id === step.id}
isPending={
mutations.approveStep.isPending ||
mutations.rejectStep.isPending
@@ -192,21 +223,56 @@ export function ContractApprovalStepsCard({
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must create a new contract this cannot be
undone.
</Text>
{returnableSteps.length > 0 && (
<Select
label="Send rejection to"
description="Return the contract to an earlier approver to fix and re-approve, or reject it to the customer."
allowDeselect={false}
value={rejectTarget}
onChange={(v) => setRejectTarget(v ?? "CUSTOMER")}
data={[
{ value: "CUSTOMER", label: "Customer — must resubmit" },
...returnableSteps.map((s) => ({
value: s.id,
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
})),
]}
/>
)}
{sendBack ? (
<Text size="sm" c="dimmed">
Contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
will go back to the{" "}
<Text span fw={600} c="dark">
{targetStep?.requiredRole}
</Text>{" "}
step. That approver fixes the contract and approves again, and
every later step re-approves in order. The customer is not
notified.
</Text>
) : (
<Text size="sm" c="dimmed">
Rejecting the{" "}
<Text span fw={600} c="dark">
{rejectStepRow?.requiredRole}
</Text>{" "}
step rejects contract{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
outright. The customer must resubmit this cannot be undone.
</Text>
)}
<Textarea
label="Reason for rejection"
description="Shared with the customer and the approval chain."
description={
sendBack
? "Shared with the approval chain (not the customer)."
: "Shared with the customer and the approval chain."
}
placeholder="Explain why this contract is rejected…"
minRows={3}
autosize
@@ -219,14 +285,16 @@ export function ContractApprovalStepsCard({
Cancel
</Button>
<Button
color="red"
color={sendBack ? "orange" : "red"}
radius="md"
leftSection={<X size={16} />}
loading={mutations.rejectStep.isPending}
disabled={!trimmedReason}
onClick={runReject}
>
Reject contract
{sendBack
? `Send back to ${targetStep?.requiredRole ?? "step"}`
: "Reject contract"}
</Button>
</Group>
</Stack>

View File

@@ -447,6 +447,17 @@ export default function GlCreateBookingForm() {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
// form entry would send.
const scopeSizeForFt = (sizeFt: number | null | undefined): string => {
if (sizeFt == null) return "";
return (
containerSizes.find((s) => parseInt(s, 10) === Number(sizeFt)) ??
`${sizeFt}ft`
);
};
setPrefilled(true);
setContainerLines(
lines.map((c) => {
@@ -466,7 +477,7 @@ export default function GlCreateBookingForm() {
}))
: Array.from({ length: qty }, emptyUnit);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
containerSize: scopeSizeForFt(c.containerType?.sizeFt),
quantity: String(qty),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
@@ -475,7 +486,7 @@ export default function GlCreateBookingForm() {
};
}),
);
}, [copyFromBooking, prefilled]);
}, [copyFromBooking, prefilled, containerSizes]);
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.

View File

@@ -23,6 +23,7 @@ const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
{ value: "CONTAINER_OPENED", label: "Container opened" },
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
{ value: "OTHER", label: "Other" },
];
const LABEL: Record<Freight.IncidentType, string> = {
@@ -30,6 +31,7 @@ const LABEL: Record<Freight.IncidentType, string> = {
CONTAINER_OPENED: "Container opened",
CONTAINER_DAMAGED: "Container damaged",
FLUID_LEAKING: "Fluid leaking",
OTHER: "Other",
};
export function IncidentReportCard({ bookingId }: { bookingId: string }) {

View File

@@ -249,6 +249,16 @@ const FleetFormDialog = ({
}
}
}
// Format check (e.g. plate numbers). Skipped for an empty optional field —
// "required" above already owns the empty case. Upper-cased to match the
// server, which stores plates upper-case.
if (field.pattern && stringValue && stringValue !== FLEET_SELECT_NONE) {
const candidate = field.pattern.uppercase === false ? stringValue : stringValue.toUpperCase();
if (!field.pattern.regex.test(candidate)) {
next[field.name] = field.pattern.message;
}
}
});
setErrors(next);
return Object.keys(next).length === 0;

View File

@@ -1,6 +1,7 @@
import { Card, Skeleton, Text } from "@mantine/core";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { ElementType, ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
@@ -22,6 +23,11 @@ export interface KpiItem {
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
*/
delta?: number;
/**
* Optional route the cell links to — its detail view. When set the cell
* becomes clickable (pointer, hover tint); when absent it stays static.
*/
href?: string;
}
export interface KpiStripProps {
@@ -47,13 +53,22 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{cells.map((item, index) => {
const Icon = item.icon;
const color = item.color ?? "edr-green";
// A cell with an href becomes a link to its detail; without one it
// stays a plain div. Same layout classes either way.
const Cell: ElementType = item.href ? Link : "div";
const linkProps = item.href
? { to: item.href, "aria-label": `${item.label} — view detail` }
: {};
return (
<div
<Cell
key={item.label}
{...(linkProps as Record<string, unknown>)}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
"cursor-pointer no-underline transition-colors hover:bg-gray-50 focus-visible:bg-gray-50",
)}
>
{Icon ? (
@@ -102,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{item.hint ? ` · ${item.hint}` : ""}
</Text>
</div>
</div>
</Cell>
);
})}
</div>

View File

@@ -46,10 +46,18 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
// Only serviceable locomotives standing in the selected yard, and not already
// coupled to another built train, can be picked. A new train owns none yet, so
// no train to keep-exclude.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
},
},
enabled: Boolean(yardId),
}),
);

View File

@@ -26,9 +26,19 @@ export default function ChangeLocomotivesModal({
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
const yardId = composition?.currentYard?.id ?? "";
// A locomotive already coupled to ANOTHER built train is not a valid pick —
// the API rejects it on save. Exclude those here (keeping this train's own
// ones, which are re-listed below as "(coupled)").
const availableQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
input: {
filters: {
status: "AVAILABLE",
currentYardId: yardId,
excludeCoupled: true,
excludeTrainId: composition?.id,
},
},
enabled: opened && Boolean(yardId),
}),
);

View File

@@ -28,6 +28,34 @@ export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Statuses that block a deactivated train from reactivating (mirrors the API gate). */
export const UNFIT_LOCOMOTIVE_STATUSES = new Set(["MAINTENANCE", "OUT_OF_SERVICE", "UNAVAILABLE"]);
/** Badge color per locomotive status (Mantine palette keys). */
export const locomotiveStatusColor = (status: string): string => {
switch (status) {
case "AVAILABLE":
case "IMPORT_READY":
case "EXPORT_READY":
return "edr-green";
case "ASSIGNED":
return "blue";
case "MAINTENANCE":
return "yellow";
case "OUT_OF_SERVICE":
case "UNAVAILABLE":
return "red";
default:
return "gray";
}
};
export const locomotiveStatusLabel = (status: string): string =>
String(status)
.toLowerCase()
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";

View File

@@ -97,6 +97,15 @@ function CorridorCell({ row }: { row: IntercityBookingRow }) {
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
/** Plain-language journey states for the accepted ride-along table. */
const INTERCITY_STATUS_META: Record<string, { label: string; color: string }> = {
SELECTED_FOR_BATCH: { label: "Awaiting payment", color: "yellow" },
APPROVED: { label: "Ready to load (gov)", color: "edr-green" },
PAID: { label: "Paid — ready to load", color: "edr-green" },
IN_TRANSIT: { label: "Loaded — in transit", color: "indigo" },
COMPLETED: { label: "Delivered", color: "teal" },
};
export function IntercityRideAlongPanel({
scheduleId,
direction,
@@ -115,10 +124,20 @@ export function IntercityRideAlongPanel({
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
// Accepting/loading/unloading a ride-along changes the schedule's booking
// list, the yard worklists AND this panel — refresh all three so the
// workspace board and yard-work tables never show a stale picture.
const invalidate = () => {
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
});
};
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
@@ -330,8 +349,14 @@ export function IntercityRideAlongPanel({
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
<Badge
size="sm"
variant="light"
color={
INTERCITY_STATUS_META[row.status ?? ""]?.color ?? "gray"
}
>
{INTERCITY_STATUS_META[row.status ?? ""]?.label ?? row.status}
</Badge>
</Table.Td>
<Table.Td>

View File

@@ -584,6 +584,15 @@ export function ScheduleWorkspacePanel({
customer={b.customer}
weightTons={b.weightTons}
status={b.status}
intercity={b.tradeDirection === "DOMESTIC"}
leg={
b.origin &&
b.destination &&
(b.originYardId !== schedule.originStation?.id ||
b.destinationYardId !== schedule.destinationStation?.id)
? `${b.origin}${b.destination}`
: null
}
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
right={
canManage ? (
@@ -840,6 +849,8 @@ function BookingCard({
status,
loadingStatus,
waitingForWagon,
intercity,
leg,
right,
}: {
reference: string;
@@ -849,6 +860,10 @@ function BookingCard({
loadingStatus?: "LOADED" | "UNLOADED";
/** Paid, but no wagon of the required type was free — waiting for one. */
waitingForWagon?: boolean;
/** DOMESTIC ride-along riding only part of this train's corridor. */
intercity?: boolean;
/** "Origin → Destination" when the booking rides a sub-corridor leg. */
leg?: string | null;
right?: React.ReactNode;
}) {
return (
@@ -874,6 +889,16 @@ function BookingCard({
{reference}
</Text>
{status ? <BookingStatusBadge status={status} /> : null}
{intercity ? (
<Tooltip
label="Intercity ride-along — rides only its own leg of this train's corridor"
withArrow
>
<Badge size="sm" radius="sm" variant="filled" color="indigo">
Intercity
</Badge>
</Tooltip>
) : null}
{waitingForWagon ? (
<Tooltip
label="Paid, but no wagon of the required type was free. Free a wagon or assign it to a same-day train that has one."
@@ -907,6 +932,11 @@ function BookingCard({
</Text>
</Group>
) : null}
{leg ? (
<Text size="xs" c="indigo.7" fw={600} style={{ whiteSpace: "nowrap" }}>
{leg}
</Text>
) : null}
</Group>
</Stack>
{right ? <Box style={{ flexShrink: 0 }}>{right}</Box> : null}

View File

@@ -2,7 +2,10 @@ import { Badge, Card, Group, Progress, SimpleGrid, Stack, Text, ThemeIcon } from
import { Box, Package } from "lucide-react";
import type { TrainScheduleWagonAllocation, WagonPlanRow } from "@/types/trainScheduling";
type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
type WagonSlot = (WagonPlanRow & {
physicalWagonNumber?: string | null;
tareWeightTons?: number | null;
}) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;

View File

@@ -201,10 +201,19 @@ export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
// Loading/unloading changes booking status on the schedule detail and the
// intercity panel too — refresh all three so no surface shows a stale state.
const invalidate = () => {
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
void queryClient.invalidateQueries({
queryKey: api.trainScheduling.scheduleDetail.queryKey({ id: scheduleId }),
});
};
const load = useMutation(
api.trainScheduling.loadScheduleBooking.mutationOptions({

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Badge, Box, Group, HoverCard, Stack, Text } from "@mantine/core";
import {
Building2,
@@ -14,6 +15,13 @@ import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Locomotive = NonNullable<TrainScheduleDetail["trainSet"]>["locomotive"];
export interface WagonLoadMove {
sourceWagonId: string;
targetWagonId: string;
}
type DragState = { sourceWagonId: string } | null;
interface InteractiveTrainConsistProps {
wagons: Wagon[];
locomotive: Locomotive | null | undefined;
@@ -23,8 +31,16 @@ interface InteractiveTrainConsistProps {
onSelectWagon: (wagon: Wagon) => void;
/** Booking id to highlight across the train (e.g. selected in the side panel). */
highlightBookingId?: string | null;
/** Wagon loads become draggable: drop on an empty wagon to move, a loaded one to swap. */
canRearrange?: boolean;
onMoveLoad?: (move: WagonLoadMove) => void;
}
const wagonItems = (wagon: Wagon) =>
(wagon.allocations ?? [])
.flatMap((a) => a.containerItems ?? [])
.sort((a, b) => (a.positionOnWagon ?? 99) - (b.positionOnWagon ?? 99));
const CONTAINER_GRADIENTS = [
"linear-gradient(180deg, var(--mantine-color-cyan-5), var(--mantine-color-cyan-7))",
"linear-gradient(180deg, var(--mantine-color-blue-5), var(--mantine-color-blue-7))",
@@ -151,16 +167,27 @@ function WagonCar({
selected,
highlighted,
onSelect,
drag,
onDragChange,
onMoveLoad,
canRearrange,
}: {
wagon: Wagon;
company: string | null;
selected: boolean;
highlighted: boolean;
onSelect: () => void;
drag: DragState;
onDragChange: (drag: DragState) => void;
onMoveLoad?: (move: WagonLoadMove) => void;
canRearrange: boolean;
}) {
const [dropHover, setDropHover] = useState(false);
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const isBulk = (wagon.allocations ?? []).some((a) =>
(a.loadType ?? "").toUpperCase().includes("BULK"),
);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
@@ -170,10 +197,20 @@ function WagonCar({
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
const containerNumbers = (allocation?.containerItems ?? []).map(
(c) => c.containerNumber?.trim() || "—",
);
const blocks = containerNumbers.slice(0, 2);
const items = wagonItems(wagon);
const blocks = items.slice(0, 2);
const containerNumbers = items.map((c) => c.containerNumber?.trim() || "—");
// The whole load drags as one unit (a 20ft pair never splits). Any OTHER
// wagon is a drop target: empty → move (a consist-only wagon repins), loaded
// → the two loads swap. The API validates wagon type + payload weight.
const draggable = canRearrange && !isEmpty;
const beingDragged = drag?.sourceWagonId === wagon.id;
const dropEligible = Boolean(drag && !beingDragged);
const endDrag = () => {
onDragChange(null);
setDropHover(false);
};
const ringColor = selected
? freightBrand.primary
@@ -189,6 +226,21 @@ function WagonCar({
style={{ width: 120, flexShrink: 0, cursor: "pointer" }}
>
<Box
onDragOver={(e) => {
if (dropEligible) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropHover(true);
}
}}
onDragLeave={() => setDropHover(false)}
onDrop={(e) => {
if (dropEligible && drag) {
e.preventDefault();
onMoveLoad?.({ sourceWagonId: drag.sourceWagonId, targetWagonId: wagon.id });
}
endDrag();
}}
style={{
position: "relative",
height: 70,
@@ -205,10 +257,16 @@ function WagonCar({
: isEmpty
? "none"
: "0 3px 10px rgba(15,41,27,0.08)",
outline: dropHover
? "2px solid var(--mantine-color-cyan-6)"
: dropEligible
? "2px dashed var(--mantine-color-cyan-4)"
: "none",
outlineOffset: 2,
overflow: "hidden",
display: "flex",
flexDirection: "column",
transition: "box-shadow 120ms ease",
transition: "box-shadow 120ms ease, outline-color 120ms ease",
}}
>
{/* top accent strip */}
@@ -243,8 +301,27 @@ function WagonCar({
)}
</Group>
{/* body */}
<Box style={{ flex: 1, padding: "3px 7px", display: "flex", alignItems: "center" }}>
{/* body — the cargo area is the drag handle for the wagon's whole load */}
<Box
draggable={draggable}
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
// Firefox needs data set for the drag to start.
e.dataTransfer.setData("text/plain", wagon.id);
onDragChange({ sourceWagonId: wagon.id });
}}
onDragEnd={endDrag}
style={{
flex: 1,
padding: "3px 7px",
display: "flex",
alignItems: "center",
cursor: draggable ? "grab" : undefined,
opacity: beingDragged ? 0.35 : 1,
transition: "opacity 120ms ease",
}}
>
{isEmpty ? (
<Text size="9px" c="dimmed" ta="center" style={{ width: "100%" }}>
Available
@@ -274,28 +351,30 @@ function WagonCar({
</Stack>
) : (
<Group gap={3} justify="center" wrap="nowrap" style={{ width: "100%" }}>
{(blocks.length ? blocks : ["—"]).map((cn, i) => (
<Box
key={i}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
}}
>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{cn}
</Text>
</Box>
))}
{(blocks.length ? blocks.map((c) => c.containerNumber?.trim() || "—") : ["—"]).map(
(cn, i) => (
<Box
key={i}
style={{
flex: 1,
minWidth: 0,
height: 26,
borderRadius: 4,
background: CONTAINER_GRADIENTS[i % CONTAINER_GRADIENTS.length],
border: `1px solid ${CONTAINER_BORDERS[i % CONTAINER_BORDERS.length]}`,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
}}
>
<Text size="8px" fw={700} c="white" truncate style={{ maxWidth: "100%" }}>
{cn}
</Text>
</Box>
),
)}
</Group>
)}
</Box>
@@ -447,7 +526,10 @@ export const InteractiveTrainConsist = ({
selectedWagonId,
onSelectWagon,
highlightBookingId,
canRearrange = false,
onMoveLoad,
}: InteractiveTrainConsistProps) => {
const [drag, setDrag] = useState<DragState>(null);
return (
<Box
style={{
@@ -477,6 +559,10 @@ export const InteractiveTrainConsist = ({
selected={selectedWagonId === wagon.id}
highlighted={Boolean(highlightBookingId && bookingId === highlightBookingId)}
onSelect={() => onSelectWagon(wagon)}
drag={drag}
onDragChange={setDrag}
onMoveLoad={onMoveLoad}
canRearrange={canRearrange}
/>
</Group>
);

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { Badge, Box, Group, Paper, Stack, Text, ThemeIcon } from "@mantine/core";
import { MousePointerClick, TrainFront } from "lucide-react";
import { isAxiosError } from "axios";
import { Hand, MousePointerClick, TrainFront } from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { TrainStatsBar } from "./TrainStatsBar";
import { WagonCard } from "./WagonCard";
import { InteractiveTrainConsist } from "./InteractiveTrainConsist";
import { InteractiveTrainConsist, type WagonLoadMove } from "./InteractiveTrainConsist";
import { RemoveBookingModal } from "./RemoveBookingModal";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
@@ -47,6 +49,7 @@ export const TrainConsistView = ({
}: TrainConsistViewProps) => {
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [removeModalOpen, setRemoveModalOpen] = useState(false);
const { toast } = useToast();
const unassignMutation = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
@@ -54,9 +57,38 @@ export const TrainConsistView = ({
const removeWagonMutation = useMutation(
api.trainScheduling.removeWagonSlot.mutationOptions(),
);
const moveLoadMutation = useMutation(
api.trainScheduling.moveWagonLoad.mutationOptions(),
);
const trainSet = scheduleDetail.trainSet;
const wagons = trainSet?.wagons ?? [];
const canRearrange = !["DISPATCHED", "ARRIVED"].includes(scheduleDetail.status);
const handleMoveLoad = async (move: WagonLoadMove) => {
if (moveLoadMutation.isPending) return;
const targetLoaded =
(wagons.find((w) => w.id === move.targetWagonId)?.allocations?.length ?? 0) > 0;
try {
await moveLoadMutation.mutateAsync({
scheduleId,
wagonId: move.sourceWagonId,
targetWagonId: move.targetWagonId,
});
toast({ title: targetLoaded ? "Wagon loads swapped" : "Load moved" });
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ?? null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check the wagon's type and payload."),
variant: "destructive",
});
}
};
// Join company/customer name from schedule bookings by booking id.
const companyByBooking = useMemo(() => {
@@ -152,13 +184,21 @@ export const TrainConsistView = ({
</div>
</Group>
<Group gap="md" wrap="nowrap" visibleFrom="sm">
{canRearrange ? (
<Group gap={5} wrap="nowrap">
<Hand size={12} color="var(--mantine-color-cyan-7)" />
<Text size="xs" c="dimmed">
Drag a wagon's cargo onto an empty wagon to move it onto a loaded one to swap
</Text>
</Group>
) : null}
<LegendDot color="cyan" label="Container" />
<LegendDot color="orange" label="Bulk" />
<LegendDot color="gray" label="Empty" dashed />
</Group>
</Group>
<Box p="md">
<Box p="md" style={{ opacity: moveLoadMutation.isPending ? 0.6 : 1 }}>
<InteractiveTrainConsist
wagons={wagons}
locomotive={trainSet?.locomotive}
@@ -166,6 +206,8 @@ export const TrainConsistView = ({
selectedWagonId={selectedWagonId}
onSelectWagon={(w) => setSelectedWagonId((prev) => (prev === w.id ? null : w.id))}
highlightBookingId={highlightBookingId}
canRearrange={canRearrange && !moveLoadMutation.isPending}
onMoveLoad={(move) => void handleMoveLoad(move)}
/>
</Box>
</Paper>
@@ -178,7 +220,7 @@ export const TrainConsistView = ({
Editing wagon #{selectedWagon.sequenceNo}
</Badge>
<Text size="xs" c="dimmed">
Update container numbers or remove the booking
Update container numbers, move containers to another wagon, or remove the booking
</Text>
</Group>
<WagonCard
@@ -192,6 +234,8 @@ export const TrainConsistView = ({
scheduleStatus={scheduleDetail.status}
onRemoveBooking={handleRemoveBooking}
onRemoveWagon={handleRemoveWagon}
wagons={wagons}
onMoveLoad={canRearrange ? (move) => void handleMoveLoad(move) : undefined}
/>
</Box>
) : wagons.length ? (
@@ -209,7 +253,8 @@ export const TrainConsistView = ({
<MousePointerClick size={13} />
</ThemeIcon>
<Text size="xs" c="dimmed">
Click a wagon in the train to edit container numbers or remove its booking.
Click a wagon to edit its containers or drag a container between wagons to
rearrange the load.
</Text>
</Group>
</Paper>

View File

@@ -1,5 +1,17 @@
import { Badge, Box, Button, Card, Group, Progress, Stack, Text, ThemeIcon } from "@mantine/core";
import {
Badge,
Box,
Button,
Card,
Group,
Menu,
Progress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
ArrowLeftRight,
Building2,
Container as ContainerIcon,
Fuel,
@@ -10,6 +22,7 @@ import {
} from "lucide-react";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
import { ContainerNumberInput } from "./ContainerNumberInput";
import type { WagonLoadMove } from "./InteractiveTrainConsist";
import { freightBrand } from "@/theme/freight-brand";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
@@ -21,8 +34,16 @@ interface WagonCardProps {
scheduleStatus?: string;
onRemoveBooking: (wagon: Wagon) => void;
onRemoveWagon: (wagonId: string) => void;
/** All wagons of the consist — targets for the move-load menu. */
wagons?: Wagon[];
onMoveLoad?: (move: WagonLoadMove) => void;
}
const itemAllocCount = (w: Wagon) => w.allocations?.length ?? 0;
const isBulkWagon = (w: Wagon) =>
(w.allocations ?? []).some((a) => (a.loadType ?? "").toUpperCase().includes("BULK"));
export const WagonCard = ({
wagon,
company,
@@ -30,6 +51,8 @@ export const WagonCard = ({
scheduleStatus,
onRemoveBooking,
onRemoveWagon,
wagons,
onMoveLoad,
}: WagonCardProps) => {
const isDispatched = scheduleStatus === "DISPATCHED";
const allocation = wagon.allocations?.[0];
@@ -153,16 +176,62 @@ export const WagonCard = ({
</Box>
{!isDispatched ? (
<Button
variant="light"
color="red"
size="xs"
leftSection={<X size={14} />}
onClick={() => onRemoveBooking(wagon)}
fullWidth
>
Remove booking
</Button>
<Group gap="xs" grow>
{onMoveLoad ? (
<Menu shadow="md" width={240} position="bottom" withinPortal>
<Menu.Target>
<Button
variant="light"
color="cyan"
size="xs"
leftSection={<ArrowLeftRight size={14} />}
>
Move load
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move this wagon's load to</Menu.Label>
{(wagons ?? [])
.filter((w) => w.id !== wagon.id)
.sort((a, b) => itemAllocCount(a) - itemAllocCount(b))
.map((w) => {
const loaded = itemAllocCount(w) > 0;
return (
<Menu.Item
key={w.id}
onClick={() =>
onMoveLoad({ sourceWagonId: wagon.id, targetWagonId: w.id })
}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Text size="xs" fw={600} truncate>
#{w.sequenceNo} ·{" "}
{w.physicalWagonNumber ?? w.wagonType?.code ?? "Wagon"}
</Text>
<Badge
size="xs"
variant="light"
color={loaded ? (isBulkWagon(w) ? "orange" : "cyan") : "gray"}
>
{loaded ? "swap" : "empty"}
</Badge>
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
) : null}
<Button
variant="light"
color="red"
size="xs"
leftSection={<X size={14} />}
onClick={() => onRemoveBooking(wagon)}
>
Remove booking
</Button>
</Group>
) : null}
</>
) : (

View File

@@ -280,3 +280,135 @@ export function RouteCorridor({
</Group>
);
}
/** Minimal booking shape the occupancy strip needs from TrainScheduleDetail. */
export type SegmentStripBooking = {
originYardId?: string | null;
destinationYardId?: string | null;
tradeDirection?: string | null;
wagonsRequired?: number | null;
};
/**
* Per-segment wagon occupancy along the corridor: which legs are full and
* which still run empty. Through cargo (unknown/off-route yards) occupies the
* whole corridor; a ride-along counts only on its own leg — this is what makes
* "export full Adama→Doraleh, intercity riding Gelan→Adama" legible at a
* glance instead of two disconnected booking lists.
*/
export function SegmentOccupancyStrip({
stops,
bookings,
maxWagons,
}: {
stops: Array<{ yardId: string; label: string }>;
bookings: SegmentStripBooking[];
maxWagons?: number | null;
}) {
if (stops.length < 2) return null;
const lastIdx = stops.length - 1;
const indexOf = new Map(stops.map((s, i) => [s.yardId, i]));
const segments = stops.slice(0, -1).map((stop, edge) => {
let cargo = 0;
let intercity = 0;
for (const b of bookings) {
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
const to =
(b.destinationYardId ? indexOf.get(b.destinationYardId) : undefined) ??
lastIdx;
const rides = from <= edge && edge < (to > from ? to : lastIdx);
if (!rides) continue;
const wagons = Number(b.wagonsRequired) || 1;
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
else cargo += wagons;
}
return { from: stop, to: stops[edge + 1], cargo, intercity };
});
const cap = Number(maxWagons) || null;
return (
<Group gap={0} wrap="nowrap" align="stretch" style={{ overflowX: "auto", paddingBottom: 4 }}>
{segments.map((seg, i) => {
const used = seg.cargo + seg.intercity;
const pct = cap ? Math.min(100, Math.round((used / cap) * 100)) : null;
const full = cap != null && used >= cap;
return (
<Group key={seg.from.yardId} gap={0} wrap="nowrap" align="stretch">
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
<Box
w={9}
h={9}
style={{
borderRadius: 999,
border: `2px solid ${freightBrand.primary}`,
background: i === 0 ? "white" : freightBrand.primary,
}}
/>
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
{seg.from.label}
</Text>
</Stack>
<Stack gap={3} px={10} pb={16} justify="flex-end" style={{ minWidth: 130 }}>
<Text size="xs" ta="center" fw={600} c={full ? "orange.8" : "dimmed"}>
{used}
{cap ? `/${cap}` : ""} wagons
{full ? " · full" : ""}
</Text>
<Box
style={{
height: 6,
borderRadius: 999,
background: "var(--mantine-color-gray-2)",
overflow: "hidden",
display: "flex",
}}
>
{cap ? (
<>
<Box
style={{
width: `${Math.min(100, (seg.cargo / cap) * 100)}%`,
background: freightBrand.primary,
}}
/>
<Box
style={{
width: `${Math.min(100, (seg.intercity / cap) * 100)}%`,
background: "var(--mantine-color-indigo-6)",
}}
/>
</>
) : (
<Box style={{ width: pct ? `${pct}%` : 0 }} />
)}
</Box>
<Text size="xs" ta="center" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{seg.cargo} cargo
{seg.intercity > 0 ? (
<Text span size="xs" fw={700} c="indigo.7">
{" "}
· {seg.intercity} intercity
</Text>
) : null}
</Text>
</Stack>
{i === segments.length - 1 ? (
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
<Box
w={9}
h={9}
style={{ borderRadius: 999, background: freightBrand.primary }}
/>
<Text size="xs" fw={600} style={{ whiteSpace: "nowrap" }}>
{seg.to.label}
</Text>
</Stack>
) : null}
</Group>
);
})}
</Group>
);
}

View File

@@ -2647,7 +2647,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
</ActionIcon>
</Tooltip>
{/* Primary stage action stays visible; the rest live under the kebab. */}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
{/* Stays visible after the first exit — multi-truck bookings
weigh each truck in and out until all have left. */}
{r.currentStatus === 'READY_FOR_PICKUP' && r.hasAssignedTruck && (
<Button
size="compact-xs"
variant="light"
@@ -2655,7 +2657,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
leftSection={<Truck size={14} />}
onClick={() => setReleaseItem(toInventoryItem(r))}
>
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
{r.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival'}
</Button>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2692,7 +2694,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
Ready for pickup
</Menu.Item>
)}
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
{r.currentStatus === 'READY_FOR_PICKUP' && (
<Menu.Item
leftSection={<Truck size={14} />}
disabled={!r.hasAssignedTruck}
@@ -2700,7 +2702,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
>
{r.hasAssignedTruck
? r.releaseOrderReference
? 'Truck leaving'
? 'Truck arrival / leaving'
: 'Truck arrival'
: 'Truck arrival — assign a truck first'}
</Menu.Item>

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Alert, Badge, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale, Truck } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast';
@@ -28,6 +28,8 @@ export interface ReleaseOrderTruckPrefill {
containerNumber?: string | null;
}
const EXIT_INSPECTION_MARKER = '[Exit Inspection]';
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
@@ -70,9 +72,6 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType;
@@ -88,29 +87,66 @@ const initialContainerNumbers = (item: WarehouseInventoryItem | null, savedConta
return Array.from({ length: expectedCount }, (_, index) => sourceNumbers[index] ?? '');
};
const parseInspectionNote = (notes: string | null | undefined) => {
const marker = '[Exit Inspection]';
const index = notes?.lastIndexOf(marker) ?? -1;
const note = index >= 0 ? notes?.slice(index + marker.length) : notes;
return {
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
};
/** One truck's saved arrival/exit weighing, parsed from its inspection block. */
interface InspectionBlock {
truckPlateNumber: string;
trailerPlateNumber: string;
driverName: string;
driverLicense: string;
driverPhone: string;
truckType: string;
containerNumber: string;
gateInTime: string;
tareWeight: number | '';
grossWeight: number | '';
netWeight: number | '';
gateOutTime: string;
weighingSkipped: boolean;
}
const parseInspectionSection = (note: string): InspectionBlock => ({
truckPlateNumber: lineValue(note, 'Truck Plate'),
trailerPlateNumber: lineValue(note, 'Trailer Plate'),
driverName: lineValue(note, 'Driver'),
driverLicense: lineValue(note, 'Driver License'),
driverPhone: lineValue(note, 'Driver Phone'),
truckType: lineValue(note, 'Truck Type'),
containerNumber: lineValue(note, 'Container Number'),
gateInTime: toLocalDateTimeInput(lineValue(note, 'Gate In Time')),
tareWeight: lineNumber(note, 'Tare Weight'),
grossWeight: lineNumber(note, 'Gross Weight'),
netWeight: lineNumber(note, 'Net Weight'),
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note),
});
/** Every truck's saved block — multi-truck bookings weigh each truck separately. */
const parseInspectionBlocks = (notes: string | null | undefined): InspectionBlock[] =>
(notes ?? '')
.split(EXIT_INSPECTION_MARKER)
.slice(1)
.map(parseInspectionSection)
.filter((block) => block.truckPlateNumber);
/** Match by plate; a legacy block may hold a comma-joined plate list. */
const blockForPlate = (blocks: InspectionBlock[], plate: string): InspectionBlock | undefined => {
const key = plate.trim().toUpperCase();
if (!key) return undefined;
return blocks.find((block) => {
const stored = block.truckPlateNumber.toUpperCase();
return stored === key || stored.split(/[,;]+/).map((p) => p.trim()).includes(key);
});
};
const blockArrived = (block: InspectionBlock | undefined) =>
Boolean(block && (block.tareWeight !== '' || block.weighingSkipped));
const blockLeft = (block: InspectionBlock | undefined) =>
Boolean(block?.gateOutTime && (block.grossWeight !== '' || block.weighingSkipped));
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
// Some openers (inventory workbench) supply bookingId without the booking
// relation — fall back to it, or the truck/container-weight queries never run.
@@ -152,78 +188,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false);
// Plate whose saved block was last loaded into the form — stops the
// per-plate loader effect from clobbering operator edits in a loop.
const loadedPlateRef = useRef<string | null>(null);
useEffect(() => {
if (opened) {
const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight);
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
setGrossWeight(inspection.grossWeight);
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
setGateOutTime(inspection.gateOutTime);
}
}, [opened, item, truckPrefill]);
const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Opened from the warehouse flow (no truckPrefill prop): once the last-mile
// truck query resolves, auto-fill the first assigned EDR truck — without
// overwriting anything the operator typed or the locked exit-step values.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
const first = lastMileTrucks[0];
if (!first) return;
setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
setDriverName((p) => p || first.driverName || '');
setDriverLicense((p) => p || first.driverLicense || '');
setDriverPhone((p) => p || first.driverPhone || '');
setTruckType((p) => p || first.truckType || '');
setContainerNumbers((prev) =>
prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
);
}, [opened, truckPrefill, isExitStep, lastMileTrucks]);
// The same for a customer self-haul truck. The prefill above reads the
// booking.customer_truck_* columns, but multi-truck self-haul writes the plate
// and driver to customer_truck_assignments and leaves those columns null — so
// a booking with a truck on file still opened this form blank. Only auto-fills
// a single truck: with several, the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPrefill || isExitStep) return;
if (customerTrucks.length !== 1) return;
const [truck] = customerTrucks;
setTruckPlateNumber((p) => p || truck.plateNumber || '');
setDriverName((p) => p || truck.driverName || '');
setTruckType((p) => p || truck.truckType || '');
setContainerNumbers((prev) => {
const loaded = (truck.containers ?? []).map((c) => c.containerNumber).filter(Boolean);
return prev.every((n) => !n) && loaded.length ? loaded : prev;
});
}, [opened, truckPrefill, isExitStep, customerTrucks]);
const savedBlocks = parseInspectionBlocks(item?.notes);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
...(truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? [
{
value: truckPrefill.truckPlateNumber,
@@ -232,6 +206,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
arrived: false,
left: false,
},
]
: []),
@@ -242,6 +219,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
...lastMileTrucks
.filter((t) => t.truckPlateNumber || t.vehicleId)
@@ -252,6 +232,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverName: t.driverName ?? '',
driverPhone: t.driverPhone ?? '',
truckType: t.truckType ?? '',
containerNumbers: splitContainerNumbers(t.containerNumber),
arrived: Boolean(t.arrivedAt),
left: Boolean(t.departedAt),
})),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
@@ -261,10 +244,132 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const truckSelectOptions = [
...new Map(assignedTruckOptions.map((t) => [t.value, t])).values(),
];
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
// 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);
// Per-truck progress: every truck is weighed in and out on its own; the saved
// blocks also cover walk-in trucks that were never formally assigned.
const truckProgress = new Map<string, { arrived: boolean; left: boolean }>();
for (const option of truckSelectOptions) {
truckProgress.set(option.value.trim().toUpperCase(), { arrived: option.arrived, left: option.left });
}
for (const block of savedBlocks) {
const key = block.truckPlateNumber.trim().toUpperCase();
const prior = truckProgress.get(key);
truckProgress.set(key, {
arrived: Boolean(prior?.arrived) || blockArrived(block),
left: Boolean(prior?.left) || blockLeft(block),
});
}
const totalTrucks = truckProgress.size;
const arrivedTrucks = [...truckProgress.values()].filter((t) => t.arrived).length;
const leftTrucks = [...truckProgress.values()].filter((t) => t.left).length;
// The step is decided PER TRUCK: the selected plate's saved block. A new plate
// (or a truck without a saved arrival) starts at the arrival step even when
// other trucks of the booking are already mid-flow or gone.
const selectedBlock = blockForPlate(savedBlocks, truckPlateNumber);
const isExitStep = blockArrived(selectedBlock);
const hasTruckLeft = blockLeft(selectedBlock);
const isEntranceLocked = isExitStep;
const selectedOption = truckSelectOptions.find(
(option) => option.value.trim().toUpperCase() === truckPlateNumber.trim().toUpperCase(),
);
// Identity comes from the arrival record or the assignment — locked either
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
/** Load a truck into the form: its saved block if any, else its assignment. */
const applyTruckSelection = (plate: string) => {
const block = blockForPlate(savedBlocks, plate);
const option = truckSelectOptions.find(
(o) => o.value.trim().toUpperCase() === plate.trim().toUpperCase(),
);
loadedPlateRef.current = plate.trim().toUpperCase();
setTruckPlateNumber(plate);
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
setDriverName(block?.driverName || option?.driverName || '');
setDriverLicense(block?.driverLicense || '');
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
setTruckType(block?.truckType || option?.truckType || '');
const loaded = block
? splitContainerNumbers(block.containerNumber)
: (option?.containerNumbers ?? []);
setContainerNumbers(loaded.length ? loaded : initialContainerNumbers(item, ''));
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
};
useEffect(() => {
if (opened) {
loadedPlateRef.current = null;
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
// Initial truck: the caller's prefill, else the first truck still mid-flow
// (arrived but not left) — the operator can switch trucks in the select.
const prefillPlate =
truckPrefill?.truckPlateNumber && !truckPrefill.truckPlateNumber.includes(',')
? truckPrefill.truckPlateNumber
: '';
const blocks = parseInspectionBlocks(item?.notes);
const inProgress = blocks.find((block) => blockArrived(block) && !blockLeft(block));
// Legacy single-truck bookings stored the truck on the booking columns; a
// comma-joined value means several trucks, so the operator picks instead.
const bookingPlate = item?.booking?.customerTruckPlateNumber ?? '';
const legacyPlate = bookingPlate && !bookingPlate.includes(',') ? bookingPlate : '';
const initialPlate = prefillPlate || inProgress?.truckPlateNumber || legacyPlate || '';
const block = blockForPlate(blocks, initialPlate);
loadedPlateRef.current = initialPlate ? initialPlate.trim().toUpperCase() : null;
setTruckPlateNumber(initialPlate);
setTrailerPlateNumber(block?.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
setDriverName(
block?.driverName ||
truckPrefill?.driverName ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckDriverName ?? '') : ''),
);
setDriverLicense(block?.driverLicense || truckPrefill?.driverLicense || '');
setDriverPhone(block?.driverPhone || truckPrefill?.driverPhone || '');
setTruckType(
block?.truckType ||
truckPrefill?.truckType ||
(legacyPlate && initialPlate === legacyPlate ? (item?.booking?.customerTruckType ?? '') : ''),
);
setContainerNumbers(
initialContainerNumbers(item, block?.containerNumber || truckPrefill?.containerNumber || ''),
);
setGateInTime(block?.gateInTime ?? '');
setTareWeight(block?.tareWeight ?? '');
setWeighTruck(block?.weighingSkipped ? 'no' : 'yes');
setGrossWeight(block?.grossWeight ?? '');
setNetWeight(block?.netWeight ?? (item?.weight == null ? '' : Number(item.weight)));
setGateOutTime(block?.gateOutTime ?? '');
}
}, [opened, item, truckPrefill]);
// No truck chosen yet and exactly one is assigned — load it. With several
// trucks the operator picks which one is at the gate.
useEffect(() => {
if (!opened || truckPlateNumber || loadedPlateRef.current) return;
if (truckSelectOptions.length !== 1) return;
applyTruckSelection(truckSelectOptions[0].value);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber, customerTrucks, lastMileTrucks]);
// A typed plate that matches a saved arrival reloads that truck's record, so
// the exit step opens with the weigh-in data instead of blank fields.
useEffect(() => {
if (!opened) return;
const key = truckPlateNumber.trim().toUpperCase();
if (!key || loadedPlateRef.current === key) return;
if (blockForPlate(savedBlocks, truckPlateNumber)) applyTruckSelection(truckPlateNumber);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, truckPlateNumber]);
// Which containers ride this truck, and their combined cargo weight. When the
// booking has container weights, that sum is the authoritative net; the
@@ -294,7 +399,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
);
// Skip is only offered for container bookings; bulk always weighs.
const skipWeighing = hasContainerWeights && weighTruck === 'no';
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
// Even an unweighed truck records the cargo weight it is holding — the
// selected containers' sum is the net that goes on the exit record.
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
const systemNetWeight = useContainerNet
? selectedCargoWeight
@@ -314,6 +421,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (hasTruckLeft) {
toast({ variant: 'destructive', title: `Truck ${truckPlateNumber} has already left — its exit record is locked` });
return;
}
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
toast({
variant: 'destructive',
@@ -363,13 +474,17 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
weighingSkipped: skipWeighing || undefined,
tareWeight: skipWeighing ? undefined : Number(tareWeight),
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
// Skipped weighing still records the net from what the truck holds.
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
},
});
await queryClient.invalidateQueries({ queryKey: ['release-customer-trucks', bookingId] });
await queryClient.invalidateQueries({ queryKey: ['release-last-mile-trucks', bookingId] });
if (!isExitStep) {
const remaining = totalTrucks > 1 ? ` (${Math.min(arrivedTrucks + 1, totalTrucks)} of ${totalTrucks} trucks arrived)` : '';
toast({
title: 'Truck arrival saved',
title: `Truck ${truckPlateNumber.trim()} arrival saved${remaining}`,
description: `${released.releaseOrderReference ?? reference} is ready for exit weighing.`,
});
onClose();
@@ -380,11 +495,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const blob = response.data;
const filename = `release-${released.booking?.reference ?? released.bookingId ?? item.id}.pdf`;
const opened = openPdfBlob(blob, filename, pdfWindow);
const remainingExit = totalTrucks > 1 ? ` ${Math.min(leftTrucks + 1, totalTrucks)} of ${totalTrucks} trucks have left.` : '';
toast({
title: 'Release exit paper issued',
description: opened
description: (opened
? 'The PDF opened in a browser tab for printing or saving.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
: 'The browser blocked the preview tab, so the PDF was downloaded.') + remainingExit,
});
onClose();
} catch (error) {
@@ -411,12 +527,35 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
</Text>
)}
</Alert>
{totalTrucks > 1 && (
<Alert icon={<Truck size={16} />} color="blue" variant="light">
<Group gap="xs">
<Text size="sm">
{totalTrucks} trucks on this booking each is weighed in and out separately.
</Text>
<Badge size="sm" variant="light" color={arrivedTrucks === totalTrucks ? 'green' : 'blue'}>
{arrivedTrucks}/{totalTrucks} arrived
</Badge>
<Badge size="sm" variant="light" color={leftTrucks === totalTrucks ? 'green' : 'gray'}>
{leftTrucks}/{totalTrucks} left
</Badge>
</Group>
</Alert>
)}
{hasTruckLeft && (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck {truckPlateNumber} has already left its exit record is locked. Pick another
truck to continue the remaining arrivals and exits.
</Text>
</Alert>
)}
<TextInput
label="Release document reference"
placeholder="e.g. REL-2026-001"
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked}
readOnly={referenceLocked}
/>
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
@@ -425,22 +564,19 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
)}
{truckSelectOptions.length > 0 && (
<Select
label="Assigned first / last-mile truck"
label="Truck at the gate"
description="Pick which assigned truck is being processed — switching trucks loads that truck's own arrival/exit record."
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}
disabled={releaseMutation.isPending || downloading}
data={truckSelectOptions.map(({ value, label, arrived, left }) => ({
value,
label: `${label}${left ? ' · LEFT' : arrived ? ' · ON SITE' : ''}`,
}))}
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);
if (value) applyTruckSelection(value);
}}
/>
)}
@@ -481,6 +617,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
data={containerSelectData}
value={selectedContainerNumbers}
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
disabled={hasTruckLeft}
/>
) : (
<Stack gap={6}>
@@ -514,13 +651,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
disabled={isEntranceLocked}
/>
{skipWeighing && (
<Text size="xs" c="dimmed">Weighbridge skipped container passes without tare/gross.</Text>
<Text size="xs" c="dimmed">
Weighbridge skipped the selected containers' cargo weight is recorded as the net.
</Text>
)}
</Group>
)}
<Group grow>
<NumberInput label="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing || hasTruckLeft} />
<NumberInput
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
min={0}
@@ -532,7 +671,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
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} />
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
@@ -546,7 +685,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading} disabled={hasTruckLeft}>
{isExitStep ? 'Save Truck Leaving & View Exit Paper' : 'Save Truck Arrival'}
</Button>
</Group>

View File

@@ -51,8 +51,10 @@ const actionColor: Record<InventoryAction, string> = {
deliver: 'green',
};
// After the first truck registers, the modal decides per truck whether it is
// arriving or leaving — the item-level label covers both for multi-truck.
const releaseActionLabel = (item: WarehouseInventoryItem) =>
item.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival';
item.releaseOrderReference ? 'Truck Arrival / Leaving' : 'Truck Arrival';
const noteLineValue = (notes: string | null | undefined, label: string) => {
const match = notes?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
@@ -276,6 +278,19 @@ export function WarehouseInventoryTable({
{nextAction === 'release' ? releaseActionLabel(item) : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{/* After the first exit the primary action flips to Deliver, but a
multi-truck booking still weighs its remaining trucks in and out. */}
{item.status === 'READY_FOR_PICKUP' && item.releaseDate && nextAction !== 'release' && (
<Button
size="compact-xs"
variant="light"
color="yellow"
loading={busy}
onClick={() => onAdvance(item, 'release')}
>
Truck Arrival / Leaving
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (
<Button
size="compact-xs"

View File

@@ -23,18 +23,24 @@ export function WarehouseOpsKpiStrip() {
delta:
data != null ? data.receivedToday - data.receivedYesterday : undefined,
hint: "vs yesterday",
// Exactly the items behind the counter: received today.
href: "/dashboard/warehouse-inventory?receivedToday=1",
},
{
label: "Pending inspection",
value: data?.pendingInspection ?? 0,
icon: ClipboardCheck,
color: "yellow",
// RECEIVED items with no inspection recorded yet.
href: "/dashboard/warehouse-inventory?pendingInspection=1",
},
{
label: "Trucks on-site",
value: data?.trucksOnSite ?? 0,
icon: Truck,
color: "blue",
// Land on the On-site tab — the counter excludes inbound trucks.
href: "/dashboard/trucks-on-site?scope=ON_SITE",
},
{
label: "Items aging (>7d)",
@@ -42,6 +48,7 @@ export function WarehouseOpsKpiStrip() {
icon: AlertTriangle,
color: (data?.itemsAging ?? 0) > 0 ? "red" : "edr-green",
hint: "In warehouse over 7 days",
href: "/dashboard/warehouse-inventory?agingOverDays=7",
},
]}
/>

View File

@@ -399,6 +399,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}`,
UPDATE_CONTAINER_ITEM: (scheduleId: string, itemId: string) =>
`/train-scheduling/schedules/${scheduleId}/container-items/${itemId}`,
MOVE_WAGON_LOAD: (scheduleId: string, wagonId: string) =>
`/train-scheduling/schedules/${scheduleId}/wagons/${wagonId}/move-load`,
UNASSIGNED_BOOKINGS: (scheduleId: string) =>
`/train-scheduling/schedules/${scheduleId}/unassigned-bookings`,
COMPOSITION_REMOVALS: (scheduleId: string) =>
@@ -427,6 +429,9 @@ export const URL_CONSTANTS = {
YARDS: "/yards",
YARD_BY_ID: (id: string) => `/yards/${id}`,
YARD_DISTANCES: "/yard-distances",
YARD_DISTANCE_BY_ID: (id: string) => `/yard-distances/${id}`,
SHIPPING_LINES: "/shipping-lines",
SHIPPING_LINE_BY_ID: (id: string) => `/shipping-lines/${id}`,

View File

@@ -1,5 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
@@ -9,16 +8,9 @@ import {
type BookingListFilter,
} from "@/services/bookings.service";
import { invalidateBookingDetail } from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
const parseApiError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
if (error instanceof Error && error.message) return error.message;
return fallback;
};
const parseApiError = extractErrorMessage;
export function useBookingList(filter?: BookingListFilter, enabled = true) {
return useQuery({
@@ -55,21 +47,21 @@ export function useBookingMutations(bookingId: string) {
mutationFn: (validityDays: number) =>
api.bookings.staffAccept.call({ id: bookingId, validityDays }),
onSuccess: (data) => onSuccess(data, "Booking accepted for approval"),
onError: () => toast.error("Failed to accept booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to accept booking")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
api.bookings.requestChanges.call({ id: bookingId, note }),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(parseApiError(error, "Failed to request changes")),
});
const staffReject = useMutation({
mutationFn: (reason: string) =>
api.bookings.staffReject.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking rejected"),
onError: () => toast.error("Failed to reject booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const reviewOperation = useMutation({
@@ -90,7 +82,7 @@ export function useBookingMutations(bookingId: string) {
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to generate contract")),
});
const signContract = useMutation({
@@ -101,32 +93,32 @@ export function useBookingMutations(bookingId: string) {
consentText?: string;
}) => bookingsService.signContract(bookingId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(parseApiError(error, "Failed to sign contract")),
});
const payBooking = useMutation({
mutationFn: () => api.bookings.payBooking.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Payment completed"),
onError: () => toast.error("Failed to complete payment"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete payment")),
});
const startTransit = useMutation({
mutationFn: () => api.bookings.startTransit.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Marked in transit"),
onError: () => toast.error("Failed to start transit"),
onError: (error) => toast.error(parseApiError(error, "Failed to start transit")),
});
const complete = useMutation({
mutationFn: () => api.bookings.complete.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Booking completed"),
onError: () => toast.error("Failed to complete booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to complete booking")),
});
const cancel = useMutation({
mutationFn: (reason: string) =>
api.bookings.cancel.call({ id: bookingId, reason }),
onSuccess: (data) => onSuccess(data, "Booking cancelled"),
onError: () => toast.error("Failed to cancel booking"),
onError: (error) => toast.error(parseApiError(error, "Failed to cancel booking")),
});
const isPending =

View File

@@ -9,6 +9,7 @@ import {
type ContractListFilter,
type SignContractPayload,
} from "@/services/contracts.service";
import { extractErrorMessage } from "@/utils/errorExtractor";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
return Promise.all([
@@ -144,7 +145,7 @@ export function useContractMutations(contractId: string) {
payload.documentSnapshot,
),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to accept contract")),
});
// Edit THIS contract's document articles (per-contract; never the templates).
@@ -152,20 +153,20 @@ export function useContractMutations(contractId: string) {
mutationFn: (snapshot: Freight.IContractDocumentSnapshot) =>
contractsService.updateContractDocument(contractId, snapshot),
onSuccess: (data) => onSuccess(data, "Contract document updated"),
onError: () => toast.error("Failed to update contract document"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to update contract document")),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to request changes")),
});
const reject = useMutation({
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject contract")),
});
const approveStep = useMutation({
@@ -182,30 +183,46 @@ export function useContractMutations(contractId: string) {
: "Approval step completed";
onSuccess(data, message);
},
onError: () => toast.error("Failed to approve step"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to approve step")),
});
// Per-step rejection by an approver (line staff / director / CEO). Terminal:
// the contract goes to REJECTED and the customer must create a new one.
// Per-step rejection by an approver (line staff / director / CEO). Two
// flavours: without returnToStepId it is terminal (REJECTED, customer must
// resubmit); with it the contract is sent back to that earlier approver and
// the chain re-runs from there.
const rejectStep = useMutation({
mutationFn: ({ stepId, reason }: { stepId: string; reason: string }) =>
contractsService.rejectStep({ id: contractId, stepId, reason }),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject step"),
mutationFn: ({
stepId,
reason,
returnToStepId,
}: {
stepId: string;
reason: string;
returnToStepId?: string;
}) =>
contractsService.rejectStep({ id: contractId, stepId, reason, returnToStepId }),
onSuccess: (data, variables) =>
onSuccess(
data,
variables.returnToStepId
? "Contract sent back in the approval chain"
: "Contract rejected",
),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to reject step")),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to generate contract")),
});
const signContract = useMutation({
mutationFn: (payload: SignContractPayload) =>
contractsService.signContract(contractId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to sign contract")),
});
const createBooking = useMutation({
@@ -331,7 +348,7 @@ export function useContractClearanceMutations(
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Could not approve all documents")),
});
const uploadOutputDocuments = useMutation({
@@ -341,7 +358,7 @@ export function useContractClearanceMutations(
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.error("Upload failed"),
onError: (error) => toast.error(extractErrorMessage(error, "Upload failed")),
});
const finalizeClearance = useMutation({
@@ -380,7 +397,7 @@ export function useCompleteMilestone(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
},
onError: () => toast.error("Failed to complete milestone"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to complete milestone")),
});
}
@@ -402,7 +419,7 @@ export function useAssignRisk(bookingId: string) {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign risk")),
});
}
@@ -420,7 +437,7 @@ export function useAdviseDuty(bookingId: string) {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to advise duty & tax")),
});
}
@@ -436,7 +453,7 @@ export function useAssignStation(bookingId: string) {
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to assign station")),
});
}
@@ -455,7 +472,7 @@ export function useUploadGlDocuments(bookingId: string) {
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to upload documents")),
});
}
@@ -483,6 +500,6 @@ export function useReportIncident(bookingId: string) {
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to report incident")),
});
}

View File

@@ -21,6 +21,7 @@ import {
invalidateRuleEngineList,
patchRuleEngineListRecord,
} from "@/utils/queryInvalidation";
import { extractErrorMessage } from "@/utils/errorExtractor";
export const useRuleEngineList = (
resource: RuleEngineResourceSlug,
@@ -58,7 +59,7 @@ export const useRuleEngineOrderMutations = (resource: RuleEngineResourceSlug) =>
await invalidateRuleEngineList(qc, resource);
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.orderList(resource) });
},
onError: () => toast.error("Failed to update order"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to update order")),
});
const moveOrder = useMutation({
@@ -273,7 +274,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, created);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to create record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to create record")),
});
const update = useMutation({
@@ -289,7 +291,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
patchRuleEngineListRecord(qc, resource, updated);
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to update record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to update record")),
});
const remove = useMutation({
@@ -299,7 +302,8 @@ export const useRuleEngineMutations = (resource: RuleEngineResourceSlug) => {
toast.success("Deleted successfully");
await invalidateRuleEngineList(qc, resource);
},
onError: () => toast.error("Failed to delete record"),
onError: (err) =>
toast.error(extractErrorMessage(err, "Failed to delete record")),
});
return { create, update, remove };
@@ -455,7 +459,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to submit rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to submit rate")),
});
const approve = useMutation({
@@ -465,7 +469,7 @@ export const useRateWorkflow = () => {
patchRuleEngineListRecord(qc, "rates", updated);
await invalidateRuleEngineList(qc, "rates");
},
onError: () => toast.error("Failed to approve rate"),
onError: (err) => toast.error(extractErrorMessage(err, "Failed to approve rate")),
});
return { submit, approve };

View File

@@ -353,12 +353,38 @@ export const POSITION_KEYS = {
djiboutiGl: "djibouti_gl",
} as const;
/** Position-type keys held by the user (e.g. "djibouti-gl-officer"). */
export function getPositionTypeKeys(
user: AuthUser | null | undefined,
): string[] {
if (!user) return [];
const keys = new Set<string>();
for (const emp of user.employee ?? []) {
for (const pos of emp.positions ?? []) {
if (pos.positionType?.key) keys.add(pos.positionType.key);
}
}
return [...keys];
}
// GL staff are identified by the root position key (department heads) OR by
// their position-type key (sub-positions: director/chief/officer) — both
// forms get the clearance-only locked view.
const ET_GL_TYPE_PREFIX = "commercial-global-logistics-(et)";
const DJ_GL_TYPE_PREFIX = "djibouti-gl";
export function isEthiopianGl(user: AuthUser | null | undefined): boolean {
return hasPosition(user, POSITION_KEYS.ethiopianGl);
return (
hasPosition(user, POSITION_KEYS.ethiopianGl) ||
getPositionTypeKeys(user).some((k) => k.startsWith(ET_GL_TYPE_PREFIX))
);
}
export function isDjiboutiGl(user: AuthUser | null | undefined): boolean {
return hasPosition(user, POSITION_KEYS.djiboutiGl);
return (
hasPosition(user, POSITION_KEYS.djiboutiGl) ||
getPositionTypeKeys(user).some((k) => k.startsWith(DJ_GL_TYPE_PREFIX))
);
}
export function isSuperAdmin(user: AuthUser | null | undefined): boolean {

View File

@@ -23,6 +23,9 @@ export const queryClient = new QueryClient({
void queryClient.invalidateQueries({ queryKey });
}
},
// Mutation failures are surfaced globally by the axios interceptor in
// auth/http.ts (server-message toast on every non-401 failure), so no
// onError toast here — it would double up.
}),
defaultOptions: {
queries: {

View File

@@ -4,3 +4,17 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Human label for a trade direction — DOMESTIC reads "Intercity" everywhere. */
export function directionLabel(direction?: string | null): string {
switch (direction) {
case "IMPORT":
return "Import";
case "EXPORT":
return "Export";
case "DOMESTIC":
return "Intercity";
default:
return direction || "—";
}
}

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -199,7 +200,7 @@ export default function ClearanceDocumentsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useLocation, useParams } from "react-router-dom";
@@ -468,7 +469,7 @@ function ClearanceHero({
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
{directionLabel(direction)}
</Badge>
{customs ? (
<Badge

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
Fragment,
useCallback,
@@ -180,7 +181,7 @@ function CustomsBadge({ customs }: { customs: boolean }) {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -1,6 +1,8 @@
import { directionLabel } from "@/lib/utils";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Box as BoxIcon,
@@ -22,6 +24,7 @@ import {
Users,
} from "lucide-react";
import {
Alert,
Badge,
Box,
Button,
@@ -264,7 +267,8 @@ export default function ContractRequestDetailPage() {
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const phasedCustoms =
@@ -428,6 +432,32 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{contract.status === "REJECTED" && contract.latestRejectionNote ? (
<Alert
color="red"
radius="md"
icon={<AlertTriangle size={18} />}
title="Rejection reason"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestRejectionNote}
</Text>
</Alert>
) : null}
{contract.status === "PENDING_APPROVAL" && contract.latestSendBackNote ? (
<Alert
color="orange"
radius="md"
icon={<AlertTriangle size={18} />}
title="Sent back in the approval chain"
>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.latestSendBackNote}
</Text>
</Alert>
) : null}
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
@@ -568,7 +598,7 @@ export default function ContractRequestDetailPage() {
<SectionCard icon={Package} title="Cargo scope">
<Group gap="sm" mb="md">
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.tradeDirection}
{directionLabel(contract.tradeDirection)}
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.freightType}

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import {
ActionIcon,
Box,
@@ -299,7 +300,7 @@ export default function ContractRequestsPage() {
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
{directionLabel(c.tradeDirection)}
</Badge>
<Badge
variant="secondary"

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
@@ -16,6 +17,7 @@ import {
} from "@mantine/core";
import {
AlertCircle,
AlertTriangle,
ClipboardList,
FileText,
Upload,
@@ -36,6 +38,8 @@ import {
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { useFileViewer } from "@/hooks/useFileViewer";
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
@@ -146,6 +150,9 @@ export default function GlClearanceDetailPage() {
"vesselDepartureDate" in data.clearance
? (data.clearance.vesselDepartureDate ?? null)
: null;
// Incident reporting attaches to a booking; a contract-level clearance can
// only report against its linked booking once one exists.
const incidentBookingId = data.kind === "booking" ? id : linkedBookingId;
// The shipment booking instance backing this clearance (per-booking GENERAL
// customs). Bare until GL completes it: no cargo, no price.
@@ -173,7 +180,7 @@ export default function GlClearanceDetailPage() {
]}
meta={
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
{data.tradeDirection}
{directionLabel(data.tradeDirection)}
</Badge>
}
action={
@@ -220,6 +227,11 @@ export default function GlClearanceDetailPage() {
>
Customs documents (all steps)
</Tabs.Tab>
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
</Tabs.Tab>
) : null}
</Tabs.List>
<Tabs.Panel value="workflow">
@@ -309,6 +321,19 @@ export default function GlClearanceDetailPage() {
</Box>
)}
</Tabs.Panel>
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Log container or seal issues discovered during clearance handling.
</Text>
<IncidentReportCard bookingId={incidentBookingId} />
</Stack>
</SectionCard>
</Tabs.Panel>
) : null}
</Tabs>
</Stack>

View File

@@ -1,3 +1,4 @@
import { directionLabel } from "@/lib/utils";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
@@ -249,7 +250,7 @@ function toContractRow(c: Freight.IContract): ContractRow {
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
const label = directionLabel(direction);
return (
<Tooltip label={label} withArrow>
<ThemeIcon

View File

@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { Card, Stack, Group, Grid, Text, ThemeIcon, Progress, Badge, Table, RingProgress, Container, Title, Box, Tabs } from '@mantine/core';
import { Truck, Fuel, Wrench, AlertCircle, Users, User, MapPin } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import Breadcrumbs from '@/components/ui/Breadcrumbs';
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
import { api } from '@/auth/http';
@@ -51,28 +52,46 @@ interface StatCardProps {
value: string | number;
color?: string;
change?: number;
/** Detail route the card opens. When set the card is a link; otherwise static. */
href?: string;
}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change }: StatCardProps) => (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}` }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
const StatCard = ({ icon: Icon, label, value, color = 'edr-green', change, href }: StatCardProps) => {
const card = (
<Card withBorder p="lg" style={{ borderTop: `3px solid ${freightBrand.primary}`, height: '100%' }}>
<Group justify="space-between" mb="sm">
<ThemeIcon size="xl" radius="md" color={color} variant="light">
<Icon size={28} />
</ThemeIcon>
</Group>
</Stack>
</Card>
);
<Stack gap="xs">
<Text size="xs" c="dimmed" fw={500} tt="uppercase">
{label}
</Text>
<Group justify="space-between">
<Text fw={700} size="xl" c="edr-ink">
{value}
</Text>
{change && <Badge color={change > 0 ? 'edr-green' : 'edr-red'} size="lg">{change > 0 ? '+' : ''}{change}%</Badge>}
</Group>
</Stack>
</Card>
);
// Wrap in a link to the detail view rather than morphing the Card itself —
// keeps Mantine's Card typing clean. Static when no href.
return href ? (
<Link
to={href}
aria-label={`${label} — view detail`}
className="block h-full cursor-pointer no-underline transition-opacity hover:opacity-90"
>
{card}
</Link>
) : (
card
);
};
export function FleetDashboard() {
const { data: vehicles = [] } = useQuery({
@@ -160,16 +179,16 @@ export function FleetDashboard() {
{/* Primary Metrics */}
<Grid mb="xl">
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" />
<StatCard icon={Truck} label="Total Vehicles" value={metrics.totalVehicles} color="edr-green" href="/dashboard/vehicles" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" />
<StatCard icon={Users} label="Total Drivers" value={metrics.totalDrivers} color="edr-blue" href="/dashboard/drivers" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" />
<StatCard icon={Fuel} label="Fuel Spend" value={etb(metrics.totalFuelSpend)} color="edr-accent" href="/dashboard/fuel-purchases" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" />
<StatCard icon={Wrench} label="Maintenance" value={etb(metrics.totalMaintenanceSpend)} color="edr-red" href="/dashboard/maintenance" />
</Grid.Col>
</Grid>

View File

@@ -19,7 +19,6 @@ import {
Divider,
Group,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -36,6 +35,7 @@ import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
import { useToast } from "@/hooks/use-toast";
import {
formatRouteLabel,
@@ -47,7 +47,7 @@ import {
} from "@/services/routes.service";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
type MilestoneFormRow = { yardId: string; distanceKm: string };
type MilestoneFormRow = { yardId: string };
type RouteFormState = {
status: RouteStatus;
@@ -56,12 +56,12 @@ type RouteFormState = {
const emptyForm = (): RouteFormState => ({
status: "AVAILABLE",
milestones: [
{ yardId: "", distanceKm: "0" },
{ yardId: "", distanceKm: "" },
],
milestones: [{ yardId: "" }, { yardId: "" }],
});
/** Order-insensitive pair key — yard distances are symmetric. */
const pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`);
const yardLabel = (yard?: YardRef | null) =>
yard ? `${yard.label} (${yard.code})` : "—";
@@ -163,6 +163,15 @@ export default function RoutesPage() {
const routesQuery = useQuery(api.routes.list.queryOptions());
const yardsQuery = useQuery(api.routes.yards.queryOptions());
// Segment km are configured in Configuration → Yard Distances and resolved
// by the API on save; this fetch is only to preview them in the form.
const yardDistancesQuery = useQuery({
queryKey: ["yard-distances", "all"],
queryFn: () =>
ruleEngineService.listAll<{ id: string; fromYardId: string; toYardId: string; distanceKm: string }>(
"yard-distances",
),
});
const createMutation = useMutation(api.routes.create.mutationOptions());
const updateMutation = useMutation(api.routes.update.mutationOptions());
const deactivateMutation = useMutation(api.routes.deactivate.mutationOptions());
@@ -206,15 +215,33 @@ export default function RoutesPage() {
[yardsQuery.data],
);
const formTotalKm = useMemo(
() =>
form.milestones.reduce(
(sum, row, index) =>
index === 0 ? sum : sum + Number(row.distanceKm || 0),
0,
),
[form.milestones],
);
const distanceByPair = useMemo(() => {
const map = new Map<string, number>();
for (const row of yardDistancesQuery.data ?? []) {
map.set(pairKey(row.fromYardId, row.toYardId), Number(row.distanceKm));
}
return map;
}, [yardDistancesQuery.data]);
/** Configured km for the segment ending at `index` (undefined = pair not configured yet). */
const segmentKm = (index: number): number | undefined => {
if (index === 0) return 0;
const from = form.milestones[index - 1]?.yardId;
const to = form.milestones[index]?.yardId;
if (!from || !to) return undefined;
return distanceByPair.get(pairKey(from, to));
};
const formTotalKm = useMemo(() => {
let total = 0;
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1]?.yardId;
const to = form.milestones[i]?.yardId;
if (!from || !to) continue;
total += distanceByPair.get(pairKey(from, to)) ?? 0;
}
return total;
}, [form.milestones, distanceByPair]);
const resetForm = () => {
setFormOpen(false);
@@ -234,10 +261,7 @@ export default function RoutesPage() {
status: route.status,
milestones: [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m, index) => ({
yardId: m.yardId,
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
})),
.map((m) => ({ yardId: m.yardId })),
});
setFormOpen(true);
};
@@ -254,7 +278,7 @@ export default function RoutesPage() {
const addMilestone = () => {
setForm((current) => ({
...current,
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
milestones: [...current.milestones, { yardId: "" }],
}));
};
@@ -267,11 +291,7 @@ export default function RoutesPage() {
const buildPayload = () => ({
status: form.status,
milestones: form.milestones.map((row, index) => ({
yardId: row.yardId,
distanceKm:
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
})),
milestones: form.milestones.map((row) => ({ yardId: row.yardId })),
});
const handleSubmit = async (event: FormEvent) => {
@@ -284,12 +304,23 @@ export default function RoutesPage() {
});
return;
}
for (let i = 1; i < form.milestones.length; i++) {
const km = Number(form.milestones[i].distanceKm);
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
// Pre-empt the API's missing-pair rejection with a readable message; if the
// distance list failed to load, skip and let the API validate.
if (yardDistancesQuery.data) {
const missing: string[] = [];
for (let i = 1; i < form.milestones.length; i++) {
const from = form.milestones[i - 1].yardId;
const to = form.milestones[i].yardId;
if (!distanceByPair.has(pairKey(from, to))) {
const label = (id: string) =>
yardOptions.find((o) => o.value === id)?.label ?? id;
missing.push(`${label(from)}${label(to)}`);
}
}
if (missing.length > 0) {
toast({
title: "Save failed",
description: `Enter segment KM for stop ${i + 1}`,
description: `No distance configured for: ${missing.join(", ")}. Add it under Configuration → Yard Distances first.`,
variant: "destructive",
});
return;
@@ -580,8 +611,11 @@ export default function RoutesPage() {
: index === form.milestones.length - 1
? "Destination"
: "Milestone";
const km = segmentKm(index);
const bothSelected =
index > 0 && Boolean(row.yardId && form.milestones[index - 1]?.yardId);
return (
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
<Group key={`${role}-${index}`} align="center" wrap="nowrap" gap="sm">
<Text w={90} size="sm" fw={500}>
{role}
</Text>
@@ -594,16 +628,25 @@ export default function RoutesPage() {
searchable
/>
{index > 0 ? (
<NumberInput
w={120}
label="KM"
min={0}
decimalScale={2}
value={row.distanceKm ? Number(row.distanceKm) : ""}
onChange={(value) =>
setMilestone(index, { distanceKm: String(value ?? "") })
}
/>
<Box w={120}>
{bothSelected ? (
km != null ? (
<Text size="sm" fw={600} ta="right">
{km} km
</Text>
) : (
<Tooltip label="No distance configured for this yard pair — add it under Configuration → Yard Distances">
<Text size="xs" c="red.7" fw={600} ta="right">
Not configured
</Text>
</Tooltip>
)
) : (
<Text size="xs" c="dimmed" ta="right">
km
</Text>
)}
</Box>
) : (
<Box w={120} />
)}
@@ -619,7 +662,8 @@ export default function RoutesPage() {
);
})}
<Text size="sm" c="dimmed">
Total route distance: <strong>{formTotalKm} km</strong>
Total route distance: <strong>{formTotalKm} km</strong> segment
distances come from Configuration Yard Distances
</Text>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={resetForm}>

View File

@@ -69,6 +69,11 @@ export interface FleetFormFieldDef extends FormFieldDef {
* (e.g. a license expiry); "past" (default) = cannot be in the future.
*/
dateBound?: "past" | "future";
/**
* Format the value must match, checked on submit. The value is upper-cased and
* trimmed before the test, matching the server. Empty optional fields skip it.
*/
pattern?: { regex: RegExp; message: string; uppercase?: boolean };
}
export interface FleetListFilterDef {

View File

@@ -1,5 +1,15 @@
import type { FleetResourceConfig } from "./resources";
/**
* A plate is two or three letters, a hyphen, then two to six digits — ET-9875,
* AA-8642. Mirrors VEHICLE_PLATE_REGEX on the API so the form and the server
* agree on what a plate looks like.
*/
const PLATE_PATTERN = {
regex: /^[A-Z]{2,3}-\d{2,6}$/,
message: "Use letters and numbers like ET-9875 or AA-8642",
};
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
{ label: "Van", value: "VAN" },
@@ -77,9 +87,9 @@ export const vehiclesConfig: FleetResourceConfig = {
],
formFields: [
{ name: "code", label: "Code", type: "text" },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true },
{ name: "plateNumber", label: "Power Plate No", type: "text", required: true, pattern: PLATE_PATTERN },
// { name: "powerPlateNo", label: "Power Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text" },
{ name: "trailerPlateNo", label: "Trailer Plate No", type: "text", pattern: PLATE_PATTERN },
{ name: "vehicleType", label: "Vehicle Type", type: "select", required: true, options: VEHICLE_TYPE_OPTIONS },
{ name: "manufacturer", label: "Manufacturer", type: "text", required: true },
{ name: "model", label: "Model", type: "text", required: true },

View File

@@ -244,7 +244,12 @@ const RuleEngineResourcePage = () => {
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
useWagonTypeOptions(usesWagonTypeField);
const usesYardField = Boolean(
config?.formFields.some((f) => f.name === "originYardId"),
config?.formFields.some(
(f) =>
f.name === "originYardId" ||
f.name === "fromYardId" ||
f.name === "toYardId",
),
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
@@ -347,6 +352,19 @@ const RuleEngineResourcePage = () => {
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
// with the direction the admin picks.
// Yard-distance endpoints have no country restriction — any yard can pair
// with any other; the other end is just excluded so A↔A can't be entered.
if (field.name === "fromYardId" || field.name === "toYardId") {
const otherEnd = field.name === "fromYardId" ? "toYardId" : "fromYardId";
return {
...field,
type: "select" as const,
optionsFromValues: (values: Record<string, unknown>) =>
(yardOptions ?? [])
.filter(({ value }) => value !== String(values[otherEnd] ?? ""))
.map(({ label, value }) => ({ label, value })),
};
}
if (field.name === "originYardId" || field.name === "destinationYardId") {
const end = field.name === "originYardId" ? "origin" : "destination";
return {
@@ -604,7 +622,7 @@ const RuleEngineResourcePage = () => {
title={config.label}
subtitle={config.subtitle}
action={
canManage ? (
canManage && config.slug !== "container-types" ? (
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
{addLabel}
</Button>

View File

@@ -1,4 +1,5 @@
import type { SidebarItem } from "@/components/layout/types";
import { ruleEngineViewKey } from "@/lib/permissions";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
@@ -348,6 +349,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text", required: true },
{ name: "capacityTons", label: "Capacity (tons)", type: "number", required: true },
{ name: "lengthMeters", label: "Length (meters)", type: "number", required: true },
@@ -370,6 +372,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "yard-distances",
label: "Yard Distances",
category: "configuration",
subtitle: "Rail distance between yard pairs — routes read their segment km from here",
searchPlaceholder: "Search by yard name or code...",
supportsSearch: true,
cardTitleKey: "fromYardLabel",
cardSubtitleKey: "toYardLabel",
columns: [
{ id: "fromYardLabel", header: "From yard", accessorKey: "fromYardLabel" },
{ id: "toYardLabel", header: "To yard", accessorKey: "toYardLabel" },
{ id: "distanceKm", header: "Distance (km)", accessorKey: "distanceKm", format: "number" },
],
formFields: [
// Options injected at render from useYardOptions (RuleEngineResourcePage).
{ name: "fromYardId", label: "From yard", type: "select", required: true, placeholder: "Select yard" },
{ name: "toYardId", label: "To yard", type: "select", required: true, placeholder: "Select yard" },
{
name: "distanceKm",
label: "Distance (km)",
type: "number",
required: true,
description:
"Symmetric — one entry covers both directions. Route segments between these yards use this value.",
},
],
},
{
slug: "priority-configs",
label: "Priority Rules",
@@ -758,6 +788,7 @@ export const getCategorySidebarChildren = (
RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({
label: r.label,
href: ruleEngineResourcePath(r.slug),
permission: ruleEngineViewKey(r.slug),
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";

View File

@@ -36,8 +36,11 @@ import ChangeYardModal from "@/components/trainBuilder/ChangeYardModal";
import ConsistWagonList from "@/components/trainBuilder/ConsistWagonList";
import {
directionColor,
locomotiveStatusColor,
locomotiveStatusLabel,
trainStatusColor,
trainStatusLabel,
UNFIT_LOCOMOTIVE_STATUSES,
} from "@/components/trainBuilder/trainStatus";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
@@ -131,6 +134,9 @@ export default function TrainBuilderDetailPage() {
const { totals } = composition;
const yard = composition.currentYard;
const blockingLocomotives = composition.locomotives.filter((loco) =>
UNFIT_LOCOMOTIVE_STATUSES.has(loco.status),
);
return (
<PageContainer>
@@ -180,6 +186,7 @@ export default function TrainBuilderDetailPage() {
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
disabled={blockingLocomotives.length > 0}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
@@ -234,6 +241,59 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{composition.status === "DEACTIVATED" && blockingLocomotives.length > 0 ? (
<Alert color="red" icon={<AlertTriangle size={16} />}>
<Stack gap="xs">
<Text size="sm">
Cannot reactivate {blockingLocomotives.length > 1 ? "these locomotives are" : "this locomotive is"}{" "}
not fit for service:{" "}
{blockingLocomotives.map((loco, i) => (
<span key={loco.id}>
{i > 0 ? ", " : ""}
<Text span fw={600} ff="monospace">
{loco.code}
</Text>{" "}
({locomotiveStatusLabel(loco.status)})
</span>
))}
.
</Text>
<Group gap="xs">
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Replace size={14} />}
disabled={!composition.editable}
onClick={() => setLocoModalOpen(true)}
>
Detach & replace locomotives
</Button>
<Button
size="compact-sm"
variant="subtle"
onClick={() => navigate(`/dashboard/locomotives`)}
>
Go to locomotives
</Button>
</Group>
</Stack>
</Alert>
) : null}
<Group gap="xs">
{composition.locomotives.map((loco) => (
<Badge
key={loco.id}
variant="light"
color={locomotiveStatusColor(loco.status)}
leftSection={<TrainFront size={12} />}
>
{loco.code} · {locomotiveStatusLabel(loco.status)}
</Badge>
))}
</Group>
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={composition.locomotives.map((loco) => ({

View File

@@ -866,7 +866,7 @@ export default function BatchScheduleDetailPage() {
>
Refresh
</Button>
{data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
{/* {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
<Button
variant="light"
color="edr-green"
@@ -876,7 +876,7 @@ export default function BatchScheduleDetailPage() {
>
Adjust consist
</Button>
) : null}
) : null} */}
{data.windowPhase === "DOC_REVIEW" ? (
<Button
color="yellow"

View File

@@ -59,6 +59,7 @@ import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWor
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import {
RouteCorridor,
SegmentOccupancyStrip,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
@@ -388,12 +389,22 @@ export default function TrainScheduleV2DetailPage() {
const unloadedCount = dispatchBookings.filter(
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
).length;
// Intercity ride-alongs load through the journey flow (Load at their origin
// yard), not the workspace toggle — dispatching before that leaves paid cargo
// stranded on the platform while its train departs.
const intercityNotLoadedCount = dispatchBookings.filter(
(b) =>
b.tradeDirection === "DOMESTIC" &&
!b.loadedAt &&
!["IN_TRANSIT", "COMPLETED"].includes(b.status ?? ""),
).length;
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
// confirmed in the workspace — surface it as a blocker, not just a warning.
const loadingBlocksDispatch =
schedule.requiresLoadingConfirmation === true &&
schedule.loadingConfirmed !== true;
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -916,17 +927,28 @@ export default function TrainScheduleV2DetailPage() {
</Text>
) : null}
</Group>
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
{(schedule.stops?.length ?? 0) >= 3 ||
(schedule.bookings ?? []).some(
(b) => b.tradeDirection === "DOMESTIC",
) ? (
<SegmentOccupancyStrip
stops={schedule.stops ?? []}
bookings={schedule.bookings ?? []}
maxWagons={schedule.maxWagons}
/>
</Box>
) : (
<Box maw={340}>
<RouteCorridor
origin={
schedule.originStation?.label ?? schedule.originStation?.code
}
destination={
schedule.destinationStation?.label ??
schedule.destinationStation?.code
}
/>
</Box>
)}
<Group gap="sm" align="center">
<FreightTypeBadge freightType={schedule.freightType} />
<StatusPill status={schedule.status} />
@@ -1059,6 +1081,14 @@ export default function TrainScheduleV2DetailPage() {
{
label: "Bookings",
value: schedule.bookings?.length ?? 0,
hint: (() => {
const intercity = (schedule.bookings ?? []).filter(
(b) => b.tradeDirection === "DOMESTIC",
).length;
return intercity > 0
? `${intercity} intercity ride-along${intercity === 1 ? "" : "s"}`
: undefined;
})(),
icon: Package,
},
{
@@ -1282,6 +1312,16 @@ export default function TrainScheduleV2DetailPage() {
unloaded
</List.Item>
) : null}
{intercityNotLoadedCount > 0 ? (
<List.Item>
<Text span fw={700}>
{intercityNotLoadedCount}
</Text>{" "}
intercity ride-along{intercityNotLoadedCount === 1 ? "" : "s"} not
loaded yet load them from the Workspace tab (Yard work) before
the train leaves their origin yard
</List.Item>
) : null}
</List>
<Text size="xs" c="dimmed" mt={6}>
You can still dispatch confirm to proceed.

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -47,15 +48,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
No trucks on site.
No trucks assigned or on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={980}>
<Table.ScrollContainer minWidth={1040}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Status</Table.Th>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
@@ -69,6 +71,16 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant={row.status === "ON_SITE" ? "filled" : "light"}
color={row.status === "ON_SITE" ? "edr-green" : "gray"}
>
{row.status === "ON_SITE" ? "On site" : "Inbound"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
@@ -101,9 +113,13 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{isLongDwell(row.arrivedAt) ? (
{row.arrivedAt == null ? (
<Text size="sm" c="dimmed">
</Text>
) : isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
@@ -124,12 +140,20 @@ function Rows({ rows }: { rows: TruckOnSite[] }) {
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
// The dashboard's "Trucks on-site" card counts only arrived trucks, so it
// deep-links here with ?scope=ON_SITE to land on the matching tab.
const [searchParams] = useSearchParams();
const scopeParam = searchParams.get("scope");
const [scope, setScope] = useState<"ALL" | "ON_SITE" | "INBOUND">(
scopeParam === "ON_SITE" || scopeParam === "INBOUND" ? scopeParam : "ALL",
);
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => scope === "ALL" || t.status === scope)
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
@@ -137,8 +161,10 @@ export default function TrucksOnSitePage() {
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, source, search]);
}, [trucks, scope, source, search]);
const onSiteCount = trucks.filter((t) => t.status === "ON_SITE").length;
const inboundCount = trucks.length - onSiteCount;
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
const edrCount = trucks.length - customerCount;
@@ -146,20 +172,32 @@ export default function TrucksOnSitePage() {
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
subtitle="Customer self-haul and EDR last-mile trucks — assigned (inbound) or arrived, until they leave the yard."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
<Group gap="sm" wrap="wrap">
<SegmentedControl
size="xs"
value={scope}
onChange={(v) => setScope(v as typeof scope)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `On site (${onSiteCount})`, value: "ON_SITE" },
{ label: `Inbound (${inboundCount})`, value: "INBOUND" },
]}
/>
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: "All", value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
</Group>
<TextInput
size="xs"
w={280}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { PackageOpen, Search, Truck } from 'lucide-react';
import { PackageOpen, Search, Truck, X } from 'lucide-react';
import { PageContainer, PageHeader } from '@/components/page';
import {
@@ -25,14 +25,36 @@ export default function WarehouseInventoryPage() {
const [filter, setFilter] = useState<InventoryFilter>(
initialStatus ? { status: initialStatus } : {},
);
// KPI drill-downs arriving from the ops dashboard cards; each shows as a
// dismissible chip so the list can be widened back out in place.
const [quick, setQuick] = useState<
Pick<InventoryFilter, 'receivedToday' | 'pendingInspection' | 'agingOverDays'>
>(() => {
const aging = Number(searchParams.get('agingOverDays'));
return {
receivedToday: searchParams.get('receivedToday') ? true : undefined,
pendingInspection: searchParams.get('pendingInspection') ? true : undefined,
agingOverDays: Number.isFinite(aging) && aging > 0 ? aging : undefined,
};
});
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const queryFilter = useMemo<InventoryFilter>(
() => ({ ...filter, direction, search: debouncedSearch || undefined }),
[filter, direction, debouncedSearch],
() => ({ ...filter, ...quick, direction, search: debouncedSearch || undefined }),
[filter, quick, direction, debouncedSearch],
);
const quickChips: Array<{ key: keyof typeof quick; label: string }> = [
...(quick.receivedToday ? [{ key: 'receivedToday' as const, label: 'Received today' }] : []),
...(quick.pendingInspection
? [{ key: 'pendingInspection' as const, label: 'Pending inspection' }]
: []),
...(quick.agingOverDays
? [{ key: 'agingOverDays' as const, label: `In warehouse >${quick.agingOverDays}d` }]
: []),
];
const warehousesQuery = useWarehouses();
const yardsQuery = useWarehouseYards(filter.warehouseId);
const zonesQuery = useWarehouseZones(filter.yardId);
@@ -136,6 +158,17 @@ export default function WarehouseInventoryPage() {
}
w={200}
/>
{quickChips.map((chip) => (
<Button
key={chip.key}
size="compact-xs"
variant="light"
rightSection={<X size={12} />}
onClick={() => setQuick((q) => ({ ...q, [chip.key]: undefined }))}
>
{chip.label}
</Button>
))}
</Group>
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />

View File

@@ -814,6 +814,20 @@ export const api = {
undefined,
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
),
moveWagonLoad: endpoint<
{ scheduleId: string; wagonId: string; targetWagonId: string },
TrainScheduleDetail
>(
"train-scheduling",
"move-wagon-load",
({ scheduleId, wagonId, targetWagonId }) =>
trainSchedulingService.moveWagonLoad(scheduleId, wagonId, {
targetWagonId,
}),
undefined,
() => [QUERY_KEYS.TRAIN_SCHEDULING.ROOT],
),
},
warehouses: {

View File

@@ -229,15 +229,26 @@ export const contractsService = {
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
/**
* Reject the current step. Without `returnToStepId` the contract is rejected
* to the customer (terminal). With it, the contract is sent back to that
* earlier approved step and the chain re-runs from there.
*/
rejectStep: ({
id,
stepId,
reason,
returnToStepId,
}: {
id: string;
stepId: string;
reason: string;
}) => postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), { reason }),
returnToStepId?: string;
}) =>
postContract<Freight.IContract>(C.REJECT_STEP(id, stepId), {
reason,
...(returnToStepId ? { returnToStepId } : {}),
}),
// ── Contract document ──
generateContract: (id: string) =>

View File

@@ -15,6 +15,10 @@ export type LocomotiveStatus =
export interface LocomotiveListFilters {
status?: LocomotiveStatus;
currentYardId?: string;
/** Drop locos already coupled to a built train (train-builder picker). */
excludeCoupled?: boolean;
/** With excludeCoupled: keep THIS train's own coupled locos in the list. */
excludeTrainId?: string;
}
export interface Locomotive {
@@ -48,6 +52,8 @@ export const locomotivesService = {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.currentYardId) params.set('currentYardId', filters.currentYardId);
if (filters.excludeCoupled) params.set('excludeCoupled', 'true');
if (filters.excludeTrainId) params.set('excludeTrainId', filters.excludeTrainId);
const qs = params.toString();
return apiClient.get<Locomotive[]>(
`${URL_CONSTANTS.LOCOMOTIVES.BASE}${qs ? `?${qs}` : ''}`,

View File

@@ -35,8 +35,9 @@ export interface RouteRecord {
milestones?: RouteMilestone[];
}
/** Segment km are resolved server-side from configured yard distances. */
export interface SaveRoutePayload {
milestones: Array<{ yardId: string; distanceKm?: number }>;
milestones: Array<{ yardId: string }>;
status?: RouteStatus;
}

View File

@@ -86,6 +86,7 @@ const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"service-types": URL_CONSTANTS.RULE_ENGINE.SERVICE_TYPES,
"weight-limit-rules": URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULES,
yards: URL_CONSTANTS.RULE_ENGINE.YARDS,
"yard-distances": URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCES,
"shipping-lines": URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINES,
rates: URL_CONSTANTS.RULE_ENGINE.RATES,
"approval-rules": URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES,
@@ -107,6 +108,8 @@ const byIdPath = (resource: RuleEngineResourceSlug, id: string): string => {
return URL_CONSTANTS.RULE_ENGINE.WEIGHT_LIMIT_RULE_BY_ID(id);
case "yards":
return URL_CONSTANTS.RULE_ENGINE.YARD_BY_ID(id);
case "yard-distances":
return URL_CONSTANTS.RULE_ENGINE.YARD_DISTANCE_BY_ID(id);
case "shipping-lines":
return URL_CONSTANTS.RULE_ENGINE.SHIPPING_LINE_BY_ID(id);
case "rates":

View File

@@ -757,6 +757,18 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
moveWagonLoad: async (
scheduleId: string,
wagonId: string,
payload: { targetWagonId: string },
): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.MOVE_WAGON_LOAD(scheduleId, wagonId),
payload,
);
return unwrap(response.data);
},
getUnassignedBookings: async (
scheduleId: string,
): Promise<UnassignedBookingsResponse> => {

View File

@@ -144,6 +144,8 @@ export interface LastMileArrivalTruck {
driverPhone: string | null;
truckType: string | null;
containerNumber: string | null;
arrivedAt: string | null;
departedAt: string | null;
}
export const warehouseService = {

View File

@@ -6,6 +6,7 @@ export type RuleEngineResourceSlug =
| "service-types"
| "weight-limit-rules"
| "yards"
| "yard-distances"
| "shipping-lines"
| "rates"
| "approval-rules";

View File

@@ -626,9 +626,20 @@ export interface TrainScheduleDetail {
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;
/** DOMESTIC = intercity ride-along; rides only its own leg below. */
tradeDirection?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
origin?: string | null;
destination?: string | null;
wagonsRequired?: number | null;
loadedAt?: string | null;
arrivedAt?: string | null;
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
warnings?: string[];
}

View File

@@ -1083,6 +1083,10 @@ export interface InventoryFilter {
search?: string;
dateFrom?: string;
dateTo?: string;
/** KPI drill-downs — mirror the ops-stats counters exactly. */
receivedToday?: boolean;
pendingInspection?: boolean;
agingOverDays?: number;
}
export interface InventoryInquiryFilter {
@@ -1128,6 +1132,8 @@ export interface WarehouseOpsStats {
export interface TruckOnSite {
source: "CUSTOMER" | "EDR";
assignmentId: string;
/** INBOUND = assigned, not yet arrived; ON_SITE = arrived, not yet departed. */
status: "INBOUND" | "ON_SITE";
plateNumber: string | null;
driverName: string | null;
truckType: string | null;

View File

@@ -0,0 +1,17 @@
/**
* Pull the SERVER's actual error message out of a failed request.
*
* NestJS returns `{ message: string | string[] }`; a class-validator failure is
* the array form (joined here). Falls back to the error's own `.message` — the
* axios response interceptor (see `auth/http.ts`) already rewrites that to the
* server message, so even code paths that never see the raw response body get
* the real cause — then to the caller's fallback string.
*/
export const extractErrorMessage = (err: unknown, fallback: string): string => {
const msg = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(msg)) return msg.filter(Boolean).join(", ");
if (typeof msg === "string" && msg) return msg;
if (err instanceof Error && err.message) return err.message;
return fallback;
};