mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user