mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit
This commit is contained in:
@@ -40,6 +40,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import type { KpiItem } from "@/components/page";
|
||||
import { EntityLink } from "@/components/detail";
|
||||
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
|
||||
@@ -80,6 +81,7 @@ import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -374,11 +376,9 @@ export default function BookingRequestDetailPage() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Carriage acceptance sheet is not available yet",
|
||||
);
|
||||
// Blob response: the JSON reason is inside the Blob, so
|
||||
// the sync path would show only "status code 400".
|
||||
toast.error(await extractDownloadErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -653,6 +653,7 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
<WagonCancellationCreditCard bookingId={booking.id} onRebooked={onRefetch} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,25 @@ export default function BookingRequestsPage() {
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Content options mirror the booking wizard's cargo picker: the group itself
|
||||
// — which the server expands to every commodity beneath it — then each
|
||||
// commodity, labelled by its full path so a generically-named leaf still
|
||||
// reads unambiguously. A group with no descendants is emitted by the
|
||||
// reference-data tree as its own single child; drop that duplicate.
|
||||
const cargoTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.cargo_type ?? []).flatMap((group) => [
|
||||
{ value: group.id, label: group.name },
|
||||
...(group.children ?? [])
|
||||
.filter((child) => child.id !== group.id)
|
||||
.map((child) => ({
|
||||
value: child.id,
|
||||
label: `${group.name} → ${child.name}`,
|
||||
})),
|
||||
]),
|
||||
[refData],
|
||||
);
|
||||
|
||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||
// the header's document-review alarm opens exactly the undecided requests
|
||||
// it is counting down for. No sync effect needed any more: controls.values
|
||||
@@ -147,6 +166,15 @@ export default function BookingRequestsPage() {
|
||||
// already mounted just works, and every filter — direction included —
|
||||
// auto-pins its own pill the moment it has a value (FilterBar's `secondary`
|
||||
// split), so a deep link can never land behind "More filters" unseen.
|
||||
// Container types, flattened out of the reference data's size groups.
|
||||
const containerTypeOptions = useMemo(
|
||||
() =>
|
||||
(refData?.containers ?? []).flatMap((group) =>
|
||||
group.types.map((t) => ({ value: t.id, label: t.name || t.code })),
|
||||
),
|
||||
[refData],
|
||||
);
|
||||
|
||||
const bookingFilterDefs: FilterDef[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -183,6 +211,65 @@ export default function BookingRequestsPage() {
|
||||
multiple: false,
|
||||
options: FREIGHT_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
// Cargo group or commodity. The group row matches its whole subtree
|
||||
// server-side, so "Bulk" returns every bulk commodity under it.
|
||||
key: "cargoTypeId",
|
||||
label: "Content",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: cargoTypeOptions,
|
||||
},
|
||||
{
|
||||
// Containers carry no customer-written description, so this is also how
|
||||
// they are reached: it matches container types ("40FT") as well as the
|
||||
// commodity name and the bulk cargo description.
|
||||
key: "cargoText",
|
||||
label: "Content contains",
|
||||
type: "text",
|
||||
secondary: true,
|
||||
placeholder: "Commodity, description or container type",
|
||||
},
|
||||
{
|
||||
key: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: containerTypeOptions,
|
||||
secondary: true,
|
||||
},
|
||||
{
|
||||
// Counts boxes. Scoped to the container-type filter when one is set, so
|
||||
// this one control answers "10 containers" and "10 forty-footers" both.
|
||||
key: "containers",
|
||||
label: "Containers",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["is", "between"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { containersMin: v.v[0], containersMax: v.v[1] }
|
||||
: { containersMin: v.v[0], containersMax: v.v[0] },
|
||||
},
|
||||
{
|
||||
// Declared on the shipment request, not yet on the booking. Pair it
|
||||
// with Containers = 0 to find the set awaiting completion.
|
||||
key: "requestedContainers",
|
||||
label: "Requested containers",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["is", "between"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? {
|
||||
requestedContainersMin: v.v[0],
|
||||
requestedContainersMax: v.v[1],
|
||||
}
|
||||
: {
|
||||
requestedContainersMin: v.v[0],
|
||||
requestedContainersMax: v.v[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "serviceTypeId",
|
||||
label: "Service",
|
||||
@@ -244,7 +331,13 @@ export default function BookingRequestsPage() {
|
||||
toParams: dateRangeParams("scheduledFrom", "scheduledTo"),
|
||||
},
|
||||
],
|
||||
[filterOptions, yardOptions, serviceTypeOptions],
|
||||
[
|
||||
filterOptions,
|
||||
yardOptions,
|
||||
serviceTypeOptions,
|
||||
cargoTypeOptions,
|
||||
containerTypeOptions,
|
||||
],
|
||||
);
|
||||
|
||||
const controls = useFilters(bookingFilterDefs, {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { AdditionalDocsRequestCard } from "@/components/bookings/detail/AdditionalDocsRequestCard";
|
||||
import { WagonCancellationCreditCard } from "@/components/bookings/wagon-cancellation";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
@@ -374,6 +375,13 @@ export default function DocumentClearanceDetailPage() {
|
||||
/>
|
||||
{booking ? <BookingCompanyCard booking={booking} /> : null}
|
||||
{booking ? <BookingContractCard booking={booking} /> : null}
|
||||
{/* Cancelled-wagon credits: GL rebooks them for the customer. */}
|
||||
{id ? (
|
||||
<WagonCancellationCreditCard
|
||||
bookingId={id}
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
|
||||
@@ -33,87 +33,15 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
type WagonCancellationStatus =
|
||||
| "FEE_PENDING"
|
||||
| "CREDIT_AVAILABLE"
|
||||
| "REBOOKED"
|
||||
| "WITHDRAWN"
|
||||
| "EXPIRED";
|
||||
|
||||
interface WagonCancellation {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rebookedBookingId?: string | null;
|
||||
wagonsCancelled: number;
|
||||
weightTons: number;
|
||||
creditAmount: number;
|
||||
feeAmount: number;
|
||||
feeCurrency: string;
|
||||
feeInvoiceId?: string | null;
|
||||
feePaidAt?: string | null;
|
||||
status: WagonCancellationStatus;
|
||||
reason?: string | null;
|
||||
rebookedAt?: string | null;
|
||||
createdAt: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
reference: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
company?: { name: string };
|
||||
};
|
||||
rebookedBooking?: { id: string; reference: string };
|
||||
feeInvoice?: { invoiceNumber: string; status: string };
|
||||
cancelledQuantities?: {
|
||||
bySize?: Record<string, number>;
|
||||
units?: Array<{
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RebookPartnerCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}
|
||||
|
||||
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
|
||||
const hasOddFt20 = (r: WagonCancellation): boolean =>
|
||||
Object.entries(r.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => parseInt(size, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
/** Editable rebook unit — prefilled from the cancelled snapshot. */
|
||||
interface RebookUnitDraft {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | "";
|
||||
}
|
||||
|
||||
interface WagonCancellationListResponse {
|
||||
items: WagonCancellation[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const STATUS_CHIP: Record<
|
||||
WagonCancellationStatus,
|
||||
{ label: string; color: string }
|
||||
> = {
|
||||
FEE_PENDING: { label: "Fee pending", color: "yellow" },
|
||||
CREDIT_AVAILABLE: { label: "Credit available", color: "edr-green" },
|
||||
REBOOKED: { label: "Rebooked", color: "indigo" },
|
||||
WITHDRAWN: { label: "Withdrawn", color: "gray" },
|
||||
EXPIRED: { label: "Expired", color: "red" },
|
||||
};
|
||||
import {
|
||||
canRebookWagonCancellations,
|
||||
isRebookableCredit,
|
||||
RebookWagonCancellationModal,
|
||||
WAGON_CANCELLATION_STATUS_CHIP as STATUS_CHIP,
|
||||
type WagonCancellation,
|
||||
type WagonCancellationListResponse,
|
||||
type WagonCancellationStatus,
|
||||
} from "@/components/bookings/wagon-cancellation";
|
||||
|
||||
const STATUS_FILTER_OPTIONS = (
|
||||
Object.keys(STATUS_CHIP) as WagonCancellationStatus[]
|
||||
@@ -155,72 +83,11 @@ export default function WagonCancellationsPage() {
|
||||
const [from, setFrom] = useState<Date | null>(null);
|
||||
const [to, setTo] = useState<Date | null>(null);
|
||||
const [voiding, setVoiding] = useState<WagonCancellation | null>(null);
|
||||
// GL rebook of a customs (Path B) credit: pick the day; container number /
|
||||
// seal / VGM may be corrected. Non-customs credits are rebooked by the
|
||||
// customer from the portal.
|
||||
const canRebook = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
// Rebook of a CREDIT_AVAILABLE row — any desk holding the rebook key, or GL
|
||||
// Ethiopia through its booking-creation key. The modal handles the day pick,
|
||||
// container corrections and the odd-20ft partner choice.
|
||||
const canRebook = canRebookWagonCancellations(user);
|
||||
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
|
||||
const [rebookDate, setRebookDate] = useState<Date | null>(null);
|
||||
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
|
||||
const openRebook = (r: WagonCancellation) => {
|
||||
setRebooking(r);
|
||||
setRebookDate(null);
|
||||
setRebookPartnerId(null);
|
||||
setRebookDrafts(
|
||||
(r.cancelledQuantities?.units ?? []).map((u) => ({
|
||||
containerSize: u.containerSize,
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? "",
|
||||
vgmTons: Number(u.vgmTons) || "",
|
||||
})),
|
||||
);
|
||||
};
|
||||
const rebookContainersPayload = () => {
|
||||
const bySize = new Map<string, RebookUnitDraft[]>();
|
||||
for (const d of rebookDrafts) {
|
||||
bySize.set(d.containerSize, [...(bySize.get(d.containerSize) ?? []), d]);
|
||||
}
|
||||
return [...bySize.entries()].map(([containerSize, units]) => ({
|
||||
containerSize,
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim(),
|
||||
...(u.sealNumber.trim() ? { sealNumber: u.sealNumber.trim() } : {}),
|
||||
...(u.vgmTons !== "" ? { vgmTons: Number(u.vgmTons) } : {}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
|
||||
scheduledDate: toDayString(rebookDate!),
|
||||
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
|
||||
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
|
||||
// the odd partner booking riding the chosen day. It ships once that partner pays.
|
||||
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
|
||||
const rebookPartners = useQuery({
|
||||
queryKey: [
|
||||
"wagon-cancellations",
|
||||
rebooking?.id,
|
||||
"rebook-partners",
|
||||
rebookDate ? toDayString(rebookDate) : null,
|
||||
],
|
||||
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
|
||||
queryFn: async () => {
|
||||
const res = await api.get<RebookPartnerCandidate[]>(
|
||||
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
|
||||
{ params: { scheduledDate: toDayString(rebookDate!) } },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
@@ -340,14 +207,12 @@ export default function WagonCancellationsPage() {
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const showVoid = r.status === "FEE_PENDING" && canVoid;
|
||||
// Customs credits are GL's to rebook; non-customs ones the customer
|
||||
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
|
||||
// re-paired with a partner booking, which only GL can pick.
|
||||
const showRebook =
|
||||
r.status === "CREDIT_AVAILABLE" &&
|
||||
canRebook &&
|
||||
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
|
||||
Number(r.creditAmount) > 0;
|
||||
// Every available credit is rebookable from here — customs or not,
|
||||
// customer- or staff-cancelled, EDR or customer fault. The customer can
|
||||
// also self-rebook plain non-customs credits from the portal, but GL
|
||||
// must be able to do it for them (customs bookings and odd-20ft
|
||||
// credits are never self-booked). The API enforces the fee gate.
|
||||
const showRebook = isRebookableCredit(r) && canRebook;
|
||||
if (!showVoid && !showRebook) return null;
|
||||
return (
|
||||
<Group justify="flex-end" wrap="nowrap">
|
||||
@@ -357,7 +222,7 @@ export default function WagonCancellationsPage() {
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => openRebook(r)}
|
||||
onClick={() => setRebooking(r)}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
@@ -518,153 +383,11 @@ export default function WagonCancellationsPage() {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
opened={!!rebooking}
|
||||
<RebookWagonCancellationModal
|
||||
cancellation={rebooking}
|
||||
onClose={() => setRebooking(null)}
|
||||
title="Rebook cancelled wagons"
|
||||
centered
|
||||
radius="md"
|
||||
>
|
||||
{rebooking && (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{rebooking.booking?.reference ?? rebooking.bookingId} ·{" "}
|
||||
{rebooking.wagonsCancelled} wagon(s) · credit{" "}
|
||||
{formatMoney(rebooking.creditAmount, rebooking.feeCurrency, 2)}
|
||||
</Text>
|
||||
<DatePickerInput
|
||||
label="Shipment day"
|
||||
placeholder="Pick the day"
|
||||
value={rebookDate}
|
||||
onChange={(v) => {
|
||||
setRebookDate(v ? new Date(v) : null);
|
||||
setRebookPartnerId(null);
|
||||
}}
|
||||
radius="md"
|
||||
/>
|
||||
{rebookNeedsPartner && (
|
||||
<Select
|
||||
label="Consolidation partner"
|
||||
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
|
||||
placeholder={
|
||||
!rebookDate
|
||||
? "Pick the day first"
|
||||
: rebookPartners.isLoading
|
||||
? "Loading…"
|
||||
: "Pick the partner booking"
|
||||
}
|
||||
data={(rebookPartners.data ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
|
||||
}))}
|
||||
value={rebookPartnerId}
|
||||
onChange={setRebookPartnerId}
|
||||
disabled={!rebookDate}
|
||||
searchable
|
||||
radius="md"
|
||||
/>
|
||||
)}
|
||||
{rebookNeedsPartner &&
|
||||
rebookDate &&
|
||||
!rebookPartners.isLoading &&
|
||||
(rebookPartners.data ?? []).length === 0 && (
|
||||
<Text size="xs" c="orange">
|
||||
No odd-20ft booking rides that day — pick another day or wait
|
||||
for a partner booking.
|
||||
</Text>
|
||||
)}
|
||||
{rebookDrafts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Correct the container details if they changed — sizes and
|
||||
quantities stay as cancelled.
|
||||
</Text>
|
||||
{rebookDrafts.map((d, i) => (
|
||||
<Group key={i} gap={8} wrap="nowrap" align="flex-end">
|
||||
<TextInput
|
||||
label={`${d.containerSize} container`}
|
||||
value={d.containerNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, containerNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1.4 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal no."
|
||||
value={d.sealNumber}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i ? { ...x, sealNumber: v } : x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="VGM (t)"
|
||||
type="number"
|
||||
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
|
||||
onChange={(e) => {
|
||||
const raw = e.currentTarget.value;
|
||||
setRebookDrafts((prev) =>
|
||||
prev.map((x, idx) =>
|
||||
idx === i
|
||||
? { ...x, vgmTons: raw === "" ? "" : Number(raw) }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
}}
|
||||
size="xs"
|
||||
radius="md"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setRebooking(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
radius="md"
|
||||
disabled={
|
||||
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
|
||||
}
|
||||
loading={rebook.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await rebook.mutateAsync();
|
||||
toast.success("Credit rebooked as a new paid booking");
|
||||
setRebooking(null);
|
||||
void refetch();
|
||||
} catch {
|
||||
// interceptor surfaces the reason
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rebook
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
onRebooked={() => void refetch()}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Hourglass,
|
||||
KeyRound,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -43,6 +44,7 @@ import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
AccountCard,
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
@@ -157,6 +159,12 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const accountsQuery = useQuery(
|
||||
api.customers.accounts.queryOptions({
|
||||
input: { companyId: id ?? "" },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const documentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -240,12 +248,61 @@ export default function CustomerDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
{/* The profile reference (EX-A00001). Minted only when a reviewer
|
||||
approves the role, so an unapproved one has none — say so
|
||||
rather than rendering an empty line that reads as a bug. */}
|
||||
{row.original.reference ? (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Ref. issued on approval
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "etradeBusiness",
|
||||
header: "eTrade business",
|
||||
cell: ({ row }) => {
|
||||
const business = row.original.etradeBusiness;
|
||||
// Not attached is a review finding, not a blank: the role names no
|
||||
// business, so there is nothing to check the uploaded licence
|
||||
// against. Companies with no eTrade record legitimately show this,
|
||||
// which is why it reads as a warning rather than an error.
|
||||
if (!business) {
|
||||
return (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Not attached
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={2} maw={230}>
|
||||
<Text size="sm" fw={600} c="edr-text" lineClamp={2}>
|
||||
{business.tradeName || "(no trade name on this licence)"}
|
||||
</Text>
|
||||
{business.activity && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{business.activity}
|
||||
</Text>
|
||||
)}
|
||||
{/* The licence number is what the reviewer matches against the
|
||||
uploaded document — trade names repeat across licences. */}
|
||||
<Text size="xs" c="dimmed">
|
||||
{business.licenceNumber}
|
||||
</Text>
|
||||
{business.renewedTo && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Renewed to {business.renewedTo}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
@@ -774,6 +831,9 @@ export default function CustomerDetailPage() {
|
||||
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
||||
Invoices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
|
||||
Account
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
@@ -981,29 +1041,29 @@ export default function CustomerDetailPage() {
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="md">
|
||||
{/* Padding sits on the header section, not the card, so the
|
||||
table runs edge to edge. minWidth carries the eTrade
|
||||
business column; the region scrolls rather than squashing
|
||||
the other columns. */}
|
||||
<TableCard
|
||||
minWidth={980}
|
||||
header={
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} c="edr-text">
|
||||
Role profiles
|
||||
</Text>
|
||||
<ProfileChips profiles={profiles} />
|
||||
</Group>
|
||||
{/* Narrower than the old full-width layout — the table
|
||||
shares the row with the people column now. */}
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={760}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={profiles}
|
||||
status="success"
|
||||
emptyMessage="No profiles registered."
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
/>
|
||||
</TableCard>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -1439,6 +1499,51 @@ export default function CustomerDetailPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* HISTORY */}
|
||||
{/* ACCOUNT — the IAM logins behind this customer. Distinct from the
|
||||
contact details on Overview: those are business contact info on the
|
||||
company row, these are the credentials someone actually signs in
|
||||
with, and the two drift apart routinely. Cards rather than a table:
|
||||
it is a handful of rows of mostly-optional detail, which a table
|
||||
renders as a field of dashes. */}
|
||||
<Tabs.Panel value="accounts" pt="lg">
|
||||
{accountsQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
) : accountsQuery.isError ? (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title="Failed to load accounts"
|
||||
>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm">
|
||||
We couldn't load this customer's portal logins.
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => void accountsQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
) : (accountsQuery.data?.length ?? 0) === 0 ? (
|
||||
<Card>
|
||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||
This customer has no portal login yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
|
||||
{accountsQuery.data?.map((account) => (
|
||||
<AccountCard key={account.profileId} account={account} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<CompanyTimeline company={company} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -108,6 +108,24 @@ const CUSTOMER_FILTER_DEFS: FilterDef[] = [
|
||||
["customer", "freight_forwarder", "dj_freight_forwarder", "transporter"] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
// The operational role, not `type` above: one `customer` company routinely
|
||||
// holds importer AND exporter, so this asks "who does X?" rather than
|
||||
// "what kind of company is this?".
|
||||
key: "profileType",
|
||||
label: "Role",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: (
|
||||
[
|
||||
"importer",
|
||||
"exporter",
|
||||
"freight_forwarder",
|
||||
"dj_freight_forwarder",
|
||||
"transporter",
|
||||
] as const
|
||||
).map((value) => ({ value, label: humanize(value) })),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: "Sector",
|
||||
@@ -348,7 +366,7 @@ export default function CustomersPage() {
|
||||
<FilterBar
|
||||
defs={CUSTOMER_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search by company, TIN, email or profile reference…"
|
||||
searchPlaceholder="Search by company, trade name, TIN, email, licence no. or profile ref…"
|
||||
sortOptions={SORT_OPTIONS.map((o) => ({ ...o }))}
|
||||
viewId="customers"
|
||||
>
|
||||
|
||||
@@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoicePaymentMethod,
|
||||
invoiceTypeLabel,
|
||||
paymentMethodLabel,
|
||||
type Invoice,
|
||||
type InvoiceListFilter,
|
||||
@@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [
|
||||
const INVOICE_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{ key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS },
|
||||
{ key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS },
|
||||
{
|
||||
key: "currency",
|
||||
label: "Currency",
|
||||
@@ -261,6 +264,15 @@ export default function InvoicesPanel() {
|
||||
size: 220,
|
||||
cell: ({ row }) => <InvoiceSourceCell invoice={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -389,7 +401,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1040}>
|
||||
<Box miw={1200}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Freight } from "@edr/types";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -7,15 +7,19 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
ExternalLink,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -25,18 +29,134 @@ import {
|
||||
formatMoney,
|
||||
humanize,
|
||||
} from "@/components/customers";
|
||||
import {
|
||||
FilterBar,
|
||||
dateRangeParams,
|
||||
isoToLocalDateStr,
|
||||
useFilters,
|
||||
type FilterDef,
|
||||
} from "@/components/filters";
|
||||
import { KpiStrip } from "@/components/page";
|
||||
import { ExportButton } from "@/components/export/ExportButton";
|
||||
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
usePagination,
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
INVOICE_TYPE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
invoiceTypeLabel,
|
||||
type InvoiceListFilter,
|
||||
type OfflineUsdInvoice,
|
||||
} from "@/types/invoice";
|
||||
import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({
|
||||
value,
|
||||
label: humanize(value),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still
|
||||
* needs settling" cut this worklist applies when no status pill is set. Only
|
||||
* the export needs it spelled out (see `exportParams`); the list gets it from
|
||||
* the server.
|
||||
*/
|
||||
const OPEN_STATUSES = [
|
||||
Freight.InvoiceStatus.Issued,
|
||||
Freight.InvoiceStatus.Pending,
|
||||
Freight.InvoiceStatus.PaymentProcessing,
|
||||
Freight.InvoiceStatus.PartiallyPaid,
|
||||
Freight.InvoiceStatus.Overdue,
|
||||
];
|
||||
|
||||
/**
|
||||
* The same filter vocabulary the invoices list uses, minus `currency` — this
|
||||
* panel is mounted once per currency and pins it from the prop, so offering it
|
||||
* as a pill could only contradict the tab you are on.
|
||||
*/
|
||||
const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [
|
||||
{ key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS },
|
||||
{
|
||||
// Computed from the balance and due date rather than read off `status` —
|
||||
// nothing sweeps PENDING rows into OVERDUE, so the status under-reports.
|
||||
key: "settlement",
|
||||
label: "Settlement",
|
||||
type: "enum",
|
||||
multiple: false,
|
||||
options: [
|
||||
{ value: "outstanding", label: "Outstanding" },
|
||||
{ value: "overdue", label: "Overdue" },
|
||||
],
|
||||
toParams: (v) =>
|
||||
v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" },
|
||||
},
|
||||
{
|
||||
key: "issued",
|
||||
label: "Issued",
|
||||
type: "date",
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("issuedFrom", "issuedTo"),
|
||||
},
|
||||
{
|
||||
key: "sources",
|
||||
label: "Source",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: SOURCE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "types",
|
||||
label: "Type",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: INVOICE_TYPE_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "paymentMethods",
|
||||
label: "Payment method",
|
||||
type: "enum",
|
||||
secondary: true,
|
||||
options: PAYMENT_METHOD_OPTIONS,
|
||||
},
|
||||
{
|
||||
key: "due",
|
||||
label: "Due",
|
||||
type: "date",
|
||||
secondary: true,
|
||||
operators: ["between", "before", "after"],
|
||||
toParams: dateRangeParams("dueFrom", "dueTo"),
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
type: "number",
|
||||
secondary: true,
|
||||
operators: ["between", "is"],
|
||||
toParams: (v) =>
|
||||
v.op === "between"
|
||||
? { minAmount: v.v[0], maxAmount: v.v[1] }
|
||||
: { minAmount: v.v[0], maxAmount: v.v[0] },
|
||||
},
|
||||
];
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "issuedAt:DESC", label: "Newest issued" },
|
||||
{ value: "issuedAt:ASC", label: "Oldest issued" },
|
||||
{ value: "dueAt:ASC", label: "Due soonest" },
|
||||
{ value: "totalAmount:DESC", label: "Largest amount" },
|
||||
{ value: "balanceAmount:DESC", label: "Largest balance" },
|
||||
{ value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" },
|
||||
];
|
||||
|
||||
/** Date params the export's `daterange` coercion expects as calendar days. */
|
||||
const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"];
|
||||
|
||||
/**
|
||||
* The customer's pay window, counted down live. Finance must confirm the bank
|
||||
@@ -157,21 +277,19 @@ export default function UsdPaymentsPanel({
|
||||
currency: "USD" | "ETB";
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||
"",
|
||||
);
|
||||
// Namespaced: the ETB and USD tabs share this panel and live on the same URL
|
||||
// as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys.
|
||||
const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, {
|
||||
defaultSort: "issuedAt:DESC",
|
||||
pageSize: 10,
|
||||
ns: "mp",
|
||||
});
|
||||
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||
const [slip, setSlip] = useState<File | null>(null);
|
||||
const [reference, setReference] = useState("");
|
||||
|
||||
const { user } = useAuth();
|
||||
const canConfirm = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
);
|
||||
const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline);
|
||||
|
||||
// Manual settlement is switched on per currency in Configuration → Manual
|
||||
// payments. FinanceHubPage hides the tab for a disabled currency; this is
|
||||
@@ -184,20 +302,8 @@ export default function UsdPaymentsPanel({
|
||||
: true;
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
currency,
|
||||
}),
|
||||
[
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
debouncedQuery,
|
||||
statusFilter,
|
||||
currency,
|
||||
],
|
||||
() => ({ ...controls.params, currency }) as unknown as InvoiceListFilter,
|
||||
[controls.params, currency],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
||||
@@ -209,7 +315,24 @@ export default function UsdPaymentsPanel({
|
||||
|
||||
const rows = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const outstanding = data?.outstanding?.[currency] ?? 0;
|
||||
|
||||
/**
|
||||
* The export's `daterange` filters are coerced from calendar days while the
|
||||
* list takes ISO instants, so each bound is handed over as the local day it
|
||||
* falls on. The worklist's implicit "still open" cut is not a URL param
|
||||
* either — spelled out here so an exported file covers the rows the screen
|
||||
* shows rather than every invoice ever raised in this currency.
|
||||
*/
|
||||
const exportParams = useMemo(() => {
|
||||
const out: Record<string, unknown> = { ...controls.params, currency };
|
||||
for (const key of EXPORT_DAY_KEYS) {
|
||||
if (typeof out[key] === "string")
|
||||
out[key] = isoToLocalDateStr(out[key] as string);
|
||||
}
|
||||
if (!out.statuses) out.statuses = OPEN_STATUSES.join(",");
|
||||
return out;
|
||||
}, [controls.params, currency]);
|
||||
|
||||
const closeConfirm = () => {
|
||||
setConfirming(null);
|
||||
@@ -310,6 +433,15 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="edr-text" truncate maw={180}>
|
||||
{invoiceTypeLabel(row.original.type)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -340,7 +472,9 @@ export default function UsdPaymentsPanel({
|
||||
header: "Pay window",
|
||||
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||
cell: ({ row }) => (
|
||||
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
|
||||
<PayWindowCell
|
||||
deadline={row.original.booking?.paymentDeadline ?? null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -358,47 +492,40 @@ export default function UsdPaymentsPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack gap="md">
|
||||
<KpiStrip
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: `Outstanding in ${currency}`,
|
||||
hint: "all matching",
|
||||
value: formatMoney(outstanding, currency),
|
||||
icon: CircleDollarSign,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Invoices listed",
|
||||
value: total,
|
||||
icon: Receipt,
|
||||
color: "blue",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search by invoice number…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
style={{ flex: 1, minWidth: "240px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<SegmentedControl
|
||||
<FilterBar
|
||||
defs={MANUAL_PAYMENT_FILTER_DEFS}
|
||||
controls={controls}
|
||||
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
|
||||
sortOptions={SORT_OPTIONS}
|
||||
viewId={`manual-payments-${currency.toLowerCase()}`}
|
||||
>
|
||||
<ExportButton
|
||||
datasetKey="invoices"
|
||||
params={exportParams}
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "open"}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(
|
||||
v === "open" ? "" : (v as Freight.InvoiceStatus),
|
||||
);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "Awaiting payment", value: "open" },
|
||||
{ label: "Paid", value: "PAID" },
|
||||
{ label: "Overdue", value: "OVERDUE" },
|
||||
]}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
@@ -410,11 +537,11 @@ export default function UsdPaymentsPanel({
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</FilterBar>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={1160}>
|
||||
<Box miw={1320}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -423,8 +550,8 @@ export default function UsdPaymentsPanel({
|
||||
emptyMessage={
|
||||
!currencyEnabled
|
||||
? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.`
|
||||
: debouncedQuery
|
||||
? "No invoices match your search."
|
||||
: controls.activeCount > 0
|
||||
? "No invoices match these filters."
|
||||
: `No ${currency} invoices awaiting manual payment confirmation.`
|
||||
}
|
||||
error={
|
||||
@@ -435,18 +562,7 @@ export default function UsdPaymentsPanel({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
{...controls.tableProps(total)}
|
||||
containerClassName="border-0 shadow-none bg-transparent"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
@@ -458,9 +574,7 @@ export default function UsdPaymentsPanel({
|
||||
<Modal
|
||||
opened={confirming !== null}
|
||||
onClose={closeConfirm}
|
||||
title={
|
||||
<Text fw={700}>Confirm manual payment</Text>
|
||||
}
|
||||
title={<Text fw={700}>Confirm manual payment</Text>}
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
@@ -510,6 +624,6 @@ export default function UsdPaymentsPanel({
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Route,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
// Marshalling 2: the current on-board list, reprinted after station work.
|
||||
// Marshalling: the on-board list, reprinted after station work. Numbered
|
||||
// per corridor stop that actually coupled/uncoupled something (Marshalling
|
||||
// 2, 3, 4…) — falls back to the single "current position" doc when nothing
|
||||
// has happened yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled:
|
||||
Boolean(scheduleId) &&
|
||||
["DISPATCHED", "ARRIVED"].includes(trackQuery.data?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
const intercityMarshalling = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
mutationFn: (stopIndex?: number) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
|
||||
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
});
|
||||
const openIntercityMarshalling = async () => {
|
||||
const openIntercityMarshalling = async (stopIndex?: number) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await intercityMarshalling.mutateAsync();
|
||||
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
||||
const blob = await intercityMarshalling.mutateAsync(stopIndex);
|
||||
const filename =
|
||||
stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Intercity marshalling ready",
|
||||
title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{inTransit || arrived ? (
|
||||
{(inTransit || arrived) && marshallingStops.length === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
{(inTransit || arrived) && marshallingStops.length > 0 ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={15} color={T.brand} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
>
|
||||
Marshalling
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
onClick={() => void openIntercityMarshalling(stop.stopIndex)}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Two-column work surface ── */}
|
||||
@@ -600,6 +641,7 @@ export default function TrainScheduleTrackPage() {
|
||||
onClose={() => setYardModal(null)}
|
||||
scheduleId={scheduleId}
|
||||
station={yardModal?.station ?? null}
|
||||
stations={track.stations}
|
||||
isFinal={yardModal?.isFinal ?? false}
|
||||
alreadyLogged={yardModal?.alreadyLogged ?? false}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
@@ -64,6 +65,11 @@ import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"
|
||||
import { ScheduleWagonYardPanel } from "@/components/trainScheduling/ScheduleWagonYardPanel";
|
||||
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import {
|
||||
PartiallyLoadedDecisionModal,
|
||||
parsePartiallyLoaded,
|
||||
type PartiallyLoadedPayload,
|
||||
} from "@/components/trainScheduling/PartiallyLoadedDecisionModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
@@ -135,10 +141,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Actual departure — staff often dispatch on paper first and record it later,
|
||||
// so the time is picked (defaults to now when the dialog opens).
|
||||
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
// Set when dispatch is rejected because a booking is part-loaded; drives the
|
||||
// EDR-fault / customer-fault decision modal.
|
||||
const [partialGate, setPartialGate] = useState<PartiallyLoadedPayload | null>(null);
|
||||
// Log-pass / arrive confirmation for the dispatched leg of the workflow.
|
||||
const [passConfirmOpen, setPassConfirmOpen] = useState(false);
|
||||
const [passAt, setPassAt] = useState<Date | null>(null);
|
||||
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
|
||||
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
|
||||
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
|
||||
@@ -155,6 +163,23 @@ export default function TrainScheduleV2DetailPage() {
|
||||
refetchInterval: 300_000,
|
||||
}),
|
||||
);
|
||||
// Journey state for the dispatched leg of the workflow: the corridor stops,
|
||||
// which one the train has reached, and each yard's loading/unloading windows.
|
||||
// Only a rolling train has a journey, so it stays idle until then.
|
||||
const trackQuery = useQuery(
|
||||
api.trainScheduling.trainTrack.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
// Which bookings board/alight at each yard — drives the loading gate on the
|
||||
// log-pass button (a yard with cargo to load must finish its window first).
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
}),
|
||||
);
|
||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||
// schedule row actually changed — same freshness as polling the detail
|
||||
// itself, at a fraction of the server cost.
|
||||
@@ -256,14 +281,38 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const recordCheckpoint = useMutation(
|
||||
api.trainScheduling.recordCheckpoint.mutationOptions(),
|
||||
);
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
||||
variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
mutationFn: ({
|
||||
id,
|
||||
direction,
|
||||
variant,
|
||||
stopIndex,
|
||||
}: {
|
||||
id: string;
|
||||
direction?: string | null;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
|
||||
: variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
// Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
|
||||
// that actually coupled/uncoupled something. Empty when nothing has yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
@@ -476,12 +525,35 @@ export default function TrainScheduleV2DetailPage() {
|
||||
b.originYardId === originYardId &&
|
||||
!b.loadedAt &&
|
||||
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
|
||||
// Paid is read from the PAYMENT status only, never booking.status.
|
||||
(b.isGovernment
|
||||
? b.status === "APPROVED" || b.status === "PAID"
|
||||
: b.status === "PAID" ||
|
||||
? b.status === "APPROVED" || b.paymentStatus === "PAID"
|
||||
: b.paymentStatus === "PAID" ||
|
||||
// Shipping-line bookings ride from accept on the credit ledger.
|
||||
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
|
||||
);
|
||||
// A slot can carry several loads of the same booking, so count DISTINCT
|
||||
// slots per booking — the operator is being told how much steel is freed.
|
||||
const slotIdsByBookingId = new Map<string, Set<string>>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
for (const alloc of slot.allocations ?? []) {
|
||||
if (!alloc.bookingId) continue;
|
||||
const slots = slotIdsByBookingId.get(alloc.bookingId) ?? new Set<string>();
|
||||
slots.add(slot.id);
|
||||
slotIdsByBookingId.set(alloc.bookingId, slots);
|
||||
}
|
||||
}
|
||||
const wagonsOf = (bookingId: string) => slotIdsByBookingId.get(bookingId)?.size ?? 0;
|
||||
|
||||
const openDispatchConfirm = () => {
|
||||
setDispatchAt(new Date());
|
||||
setDispatchConfirmOpen(true);
|
||||
};
|
||||
// Everything unloaded at the origin comes off the train on dispatch.
|
||||
// Government bookings can never be shed: the server refuses to unassign them.
|
||||
const leftBehind = pendingOriginBoarders.filter((b) => !b.isGovernment);
|
||||
const leftBehindWagons = leftBehind.reduce((n, b) => n + wagonsOf(b.id), 0);
|
||||
|
||||
// Origin loading time window: dispatch (which marks the boarders loaded)
|
||||
// is server-rejected until "Start loading" was clicked for the origin
|
||||
// yard, so the button mirrors that gate.
|
||||
@@ -495,6 +567,89 @@ export default function TrainScheduleV2DetailPage() {
|
||||
// Same gate the server enforces.
|
||||
const dispatchBlockedByLoading = !originLoadingEnded;
|
||||
|
||||
// ── Journey leg: log pass / mark arrived ────────────────────────────────
|
||||
// Once the train is rolling, the workflow's last step drives the corridor
|
||||
// instead of dispatch. The stop being logged is the one AFTER the train's
|
||||
// current position; the last stop on the route is the arrival.
|
||||
const track = trackQuery.data;
|
||||
const trackStations = track?.stations ?? [];
|
||||
const isRolling = schedule.status === "DISPATCHED";
|
||||
const nextStation = isRolling
|
||||
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0) + 1)
|
||||
: undefined;
|
||||
const nextIsFinal =
|
||||
Boolean(nextStation) &&
|
||||
nextStation?.sequenceNo === trackStations[trackStations.length - 1]?.sequenceNo;
|
||||
// Same permission the track page gates its checkpoint actions on.
|
||||
const canLogPass =
|
||||
isRolling && hasPermission(authUser, FREIGHT_PERMS.trainScheduling.update);
|
||||
// Loading gate. Logging a pass means the train LEAVES the yard it is standing
|
||||
// at, so cargo boarding there must have finished loading first — an open (or
|
||||
// never-opened) loading window at a yard with boarders blocks the button.
|
||||
// Unloading never blocks: cargo alighting here can be taken off after the
|
||||
// pass is recorded, and the final arrival is what opens that window at all.
|
||||
const currentStation = isRolling
|
||||
? trackStations.find((st) => st.sequenceNo === (track?.currentSequenceNo ?? 0))
|
||||
: undefined;
|
||||
const currentYardWork = currentStation
|
||||
? yardWorkQuery.data?.yards.find((y) => y.yardId === currentStation.yardId)
|
||||
: undefined;
|
||||
const boardersHere = (currentYardWork?.toLoad ?? []).filter((r) => !r.loadedAt);
|
||||
const currentLoadingLog = currentStation
|
||||
? track?.stationWorkLogs?.[currentStation.yardId]?.loading
|
||||
: undefined;
|
||||
// Only a yard that actually has cargo to load can be blocked by its window.
|
||||
const passBlockedByLoading =
|
||||
boardersHere.length > 0 && !currentLoadingLog?.endedAt;
|
||||
const passBlockReason = !passBlockedByLoading
|
||||
? null
|
||||
: currentLoadingLog?.startedAt
|
||||
? `End the loading window at ${currentStation?.label ?? "this yard"} — the train cannot leave mid-loading.`
|
||||
: `Start and end the loading window at ${currentStation?.label ?? "this yard"} — ${boardersHere.length} booking(s) board here.`;
|
||||
|
||||
const openPassConfirm = () => {
|
||||
setPassAt(new Date());
|
||||
setPassConfirmOpen(true);
|
||||
};
|
||||
|
||||
const runLogPass = async () => {
|
||||
if (!nextStation) return;
|
||||
setPassConfirmOpen(false);
|
||||
try {
|
||||
await recordCheckpoint.mutateAsync({
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
sequenceNo: nextStation.sequenceNo,
|
||||
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
|
||||
},
|
||||
});
|
||||
toast({
|
||||
title: nextIsFinal
|
||||
? `Train arrived at ${nextStation.label}`
|
||||
: `Pass logged at ${nextStation.label}`,
|
||||
description: nextIsFinal
|
||||
? "Remaining bookings are marked arrived and the assets are freed."
|
||||
: "The train's position has moved to this yard.",
|
||||
});
|
||||
void trackQuery.refetch();
|
||||
void yardWorkQuery.refetch();
|
||||
void detailQuery.refetch();
|
||||
} catch (err) {
|
||||
// A part-loaded booking blocks the pass until its never-loaded wagons are
|
||||
// cut — hand over the fault decision rather than a dead-end error.
|
||||
const gate = parsePartiallyLoaded(err);
|
||||
if (gate) {
|
||||
setPartialGate(gate);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: nextIsFinal ? "Could not mark arrived" : "Could not log pass",
|
||||
description: parseError(err, "Please try again"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling =
|
||||
@@ -506,6 +661,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
@@ -513,13 +669,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
variant: options?.variant,
|
||||
stopIndex: options?.stopIndex,
|
||||
});
|
||||
const prefix =
|
||||
options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
options?.stopIndex != null
|
||||
? `marshalling-${options.stopIndex}`
|
||||
: options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
@@ -547,17 +706,34 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
payload: {
|
||||
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
|
||||
// No per-booking ticking in the dispatch dialog: every pending origin
|
||||
// boarder rides — none are left behind at dispatch time.
|
||||
loadedBookingIds: pendingOriginBoarders.map((b) => b.id),
|
||||
// Dispatch never loads cargo — loading is recorded in the yard, per
|
||||
// booking. Anything still unloaded when the train leaves did not make
|
||||
// it aboard: the server unassigns it (wagons freed, booking back in
|
||||
// the pool). Government bookings are exempt and ride regardless.
|
||||
loadedBookingIds: pendingOriginBoarders
|
||||
.filter((b) => b.isGovernment)
|
||||
.map((b) => b.id),
|
||||
},
|
||||
});
|
||||
if (leftBehind.length) {
|
||||
toast({
|
||||
title: `${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} removed from the train`,
|
||||
description: `Never loaded at the origin — ${leftBehindWagons} wagon${leftBehindWagons === 1 ? "" : "s"} freed. The bookings are back in the pool and can be allocated to another train or cancelled.`,
|
||||
});
|
||||
}
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
// A part-loaded booking blocks dispatch until its never-loaded wagons are
|
||||
// cut — hand the operator the fault decision instead of a dead error.
|
||||
const gate = parsePartiallyLoaded(err);
|
||||
if (gate) {
|
||||
setPartialGate(gate);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
@@ -680,8 +856,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
{
|
||||
key: "finalize",
|
||||
icon: CheckCircle2,
|
||||
title: "Dispatch",
|
||||
subtitle: "Review the consist & dispatch",
|
||||
title: isRolling ? "Journey" : "Dispatch",
|
||||
subtitle: isRolling
|
||||
? "Log each pass, then mark arrived"
|
||||
: "Review the consist & dispatch",
|
||||
complete: finalizeComplete,
|
||||
},
|
||||
];
|
||||
@@ -933,14 +1111,51 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<CheckCircle2 size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>Ready to depart</Text>
|
||||
<Text fw={600}>
|
||||
{isRolling
|
||||
? nextIsFinal
|
||||
? "Final leg"
|
||||
: `In transit — at ${currentStation?.label ?? "the corridor"}`
|
||||
: "Ready to depart"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Dispatch begins rail movement and notifies the yard.
|
||||
{isRolling
|
||||
? nextIsFinal
|
||||
? "Marking arrived ends the journey and frees the locomotive and wagons."
|
||||
: "Logging the pass moves the train to the next yard and settles its cargo there."
|
||||
: "Dispatch begins rail movement and notifies the yard."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
{originYardId ? (
|
||||
{/* Mid-route loading/unloading is recorded on the TRACKING page, per
|
||||
yard — only the origin's window lives here (below), because dispatch
|
||||
is the action this page owns. What stays is the read-only reason the
|
||||
pass button is held, so the blocker is explainable without
|
||||
duplicating the controls. */}
|
||||
{isRolling && currentStation && passBlockedByLoading ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title={`Loading is not finished at ${currentStation.label}`}
|
||||
>
|
||||
<Text size="xs">
|
||||
{boardersHere.length} booking(s) board here, so the train cannot leave until
|
||||
the loading window is closed. Start and end it on the{" "}
|
||||
<Anchor
|
||||
component={Link}
|
||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}/track`}
|
||||
fw={600}
|
||||
>
|
||||
tracking page
|
||||
</Anchor>
|
||||
.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isRolling && originYardId ? (
|
||||
<Paper p="md" radius="lg" withBorder>
|
||||
<Stack gap={6}>
|
||||
<Text fw={600} size="sm">
|
||||
@@ -975,7 +1190,35 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Dispatch train
|
||||
</Button>
|
||||
) : null}
|
||||
{!canDispatch ? (
|
||||
{/* The train is rolling: the same slot now drives the corridor. */}
|
||||
{canLogPass && nextStation ? (
|
||||
<Tooltip
|
||||
label={passBlockReason ?? ""}
|
||||
disabled={!passBlockedByLoading}
|
||||
withArrow
|
||||
multiline
|
||||
w={280}
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="md"
|
||||
radius="md"
|
||||
leftSection={
|
||||
nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />
|
||||
}
|
||||
loading={recordCheckpoint.isPending}
|
||||
disabled={passBlockedByLoading}
|
||||
onClick={openPassConfirm}
|
||||
>
|
||||
{nextIsFinal
|
||||
? `Mark arrived at ${nextStation.label}`
|
||||
: `Log pass at ${nextStation.label}`}
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!canDispatch && !(canLogPass && nextStation) ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No actions available for this schedule status.
|
||||
</Text>
|
||||
@@ -1108,7 +1351,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Marshalling PDF
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
@@ -1122,6 +1365,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Intercity Marshalling
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{/* One item per corridor stop that actually coupled/uncoupled
|
||||
something (Marshalling 2, 3, 4…) — replaces the single
|
||||
"current position" item once anything has happened. */}
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: `Marshalling ${stop.stopIndex} ready`,
|
||||
successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
|
||||
stopIndex: stop.stopIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
@@ -1592,6 +1854,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
{leftBehind.length > 0 ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title={`${leftBehind.length} booking${leftBehind.length === 1 ? "" : "s"} will be removed from this train`}
|
||||
>
|
||||
<Text size="xs">
|
||||
Never loaded at the origin, so {leftBehind.length === 1 ? "it is" : "they are"}{" "}
|
||||
not aboard. Dispatch frees {leftBehindWagons} wagon
|
||||
{leftBehindWagons === 1 ? "" : "s"} and returns{" "}
|
||||
{leftBehind.length === 1 ? "the booking" : "them"} to the pool, ready to be
|
||||
allocated to another train or cancelled. Load cargo from the yard workspace
|
||||
before dispatching if it should ride.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
@@ -1673,6 +1954,62 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
{/* Log pass / arrival — confirmation only, with the recorded time. */}
|
||||
<Modal
|
||||
opened={passConfirmOpen}
|
||||
onClose={() => setPassConfirmOpen(false)}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
{nextIsFinal ? <CheckCircle2 size={18} /> : <Navigation size={18} />}
|
||||
<Text fw={700}>
|
||||
{nextIsFinal ? "Mark the train arrived?" : "Log the pass?"}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{nextIsFinal
|
||||
? `Recording arrival at ${nextStation?.label ?? "the destination"} ends the journey: remaining bookings are marked arrived and the locomotive and wagons are freed.`
|
||||
: `Recording the pass at ${nextStation?.label ?? "the next yard"} moves the train there. Cargo destined for that yard alights, and cargo boarding there becomes loadable.`}
|
||||
</Text>
|
||||
|
||||
<DateTimePicker
|
||||
label={nextIsFinal ? "Arrival time" : "Time at station"}
|
||||
description="Defaults to now — pick an earlier time if you are recording after the fact."
|
||||
value={passAt}
|
||||
onChange={(v) => setPassAt(v ? new Date(v) : null)}
|
||||
maxDate={new Date()}
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
clearable={false}
|
||||
radius="md"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setPassConfirmOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={recordCheckpoint.isPending}
|
||||
onClick={() => void runLogPass()}
|
||||
>
|
||||
{nextIsFinal ? "Mark arrived" : "Log pass"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Part-loaded gate. Cutting the wagons does NOT dispatch — the operator
|
||||
confirms dispatch again once the consist is clean. */}
|
||||
<PartiallyLoadedDecisionModal
|
||||
payload={partialGate}
|
||||
onClose={() => setPartialGate(null)}
|
||||
onResolved={() => void detailQuery.refetch()}
|
||||
/>
|
||||
{visualization3DOpen ? (
|
||||
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
|
||||
) : null}
|
||||
|
||||
@@ -158,6 +158,11 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [trainId, setTrainId] = useState("");
|
||||
// Voyage number for this departure — required. Auto-filled from the selected
|
||||
// train's own voyage number (typed in the Train Builder) when a train is
|
||||
// picked; legacy trains without one fall back to the direction-matched run
|
||||
// number. Staff may edit.
|
||||
const [voyageNumber, setVoyageNumber] = useState("");
|
||||
const [reverseWagonOrder, setReverseWagonOrder] = useState(false);
|
||||
// "" = a normal customer train; an id dedicates the departure to that
|
||||
// shipping line and hides it from every customer-facing view.
|
||||
@@ -461,6 +466,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!voyageNumber.trim()) {
|
||||
toast({
|
||||
title: "Voyage number is required",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Only build the window override when the toggle is on — off means "inherit
|
||||
// the global rules", which the API expresses as an absent windowRule.
|
||||
let windowRule: CreateScheduleWindowRulePayload | undefined;
|
||||
@@ -483,6 +495,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
routeId,
|
||||
scheduleDate: new Date(scheduleDate).toISOString(),
|
||||
trainId,
|
||||
voyageNumber: voyageNumber.trim(),
|
||||
reverseWagonOrder,
|
||||
...(shippingLineCompanyId ? { shippingLineCompanyId } : {}),
|
||||
...(windowRule ? { windowRule } : {}),
|
||||
@@ -490,6 +503,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
});
|
||||
toast({ title: "Train schedule created" });
|
||||
showScheduleWarnings(created.warnings);
|
||||
setVoyageNumber("");
|
||||
setReverseWagonOrder(false);
|
||||
setShippingLineCompanyId("");
|
||||
setConfigureWindow(false);
|
||||
@@ -689,7 +703,20 @@ export default function TrainScheduleV2ListPage() {
|
||||
};
|
||||
})}
|
||||
value={trainId || null}
|
||||
onChange={(v) => setTrainId(v ?? "")}
|
||||
onChange={(v) => {
|
||||
setTrainId(v ?? "");
|
||||
// Default the voyage number to the picked train's own voyage
|
||||
// number (the Train Builder stores it as `trainName`). The run
|
||||
// number is a train number, not a voyage — only fall back to it
|
||||
// for legacy trains that have no voyage number yet; staff can
|
||||
// still override.
|
||||
const picked = (trainsQuery.data ?? []).find((t) => t.id === v);
|
||||
const runNumber =
|
||||
selectedRoute?.direction === "IMPORT"
|
||||
? picked?.importTrainNumber
|
||||
: picked?.exportTrainNumber;
|
||||
setVoyageNumber(picked?.trainName?.trim() || runNumber || "");
|
||||
}}
|
||||
searchable
|
||||
disabled={!routeId}
|
||||
nothingFoundMessage={
|
||||
@@ -698,6 +725,15 @@ export default function TrainScheduleV2ListPage() {
|
||||
: "Select a route first"
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Voyage number"
|
||||
description="Sailing/run number for this departure that yards and customs quote. Defaults to the selected train's voyage number — edit if needed."
|
||||
placeholder={trainId ? "e.g. V-2026-0620" : "Select a train first"}
|
||||
required
|
||||
maxLength={20}
|
||||
value={voyageNumber}
|
||||
onChange={(e) => setVoyageNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Shipping line (optional)"
|
||||
description="Dedicate this departure to one shipping line. The train is then hidden from customers and shown only in that line's portal."
|
||||
@@ -930,7 +966,12 @@ function TrainIdentityCell({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||
let subtitle = "";
|
||||
if (schedule.train) {
|
||||
title = schedule.trainNumber ?? schedule.train.code;
|
||||
subtitle = [schedule.trainNumber ? schedule.train.code : null, schedule.train.trainName]
|
||||
// Show THIS departure's voyage number (the schedule's own), not the train's
|
||||
// voyage/name — one train serves many departures, each with its own voyage.
|
||||
subtitle = [
|
||||
schedule.trainNumber ? schedule.train.code : null,
|
||||
schedule.voyageNumber ? `Voyage ${schedule.voyageNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
} else if (locos.length) {
|
||||
|
||||
@@ -18,15 +18,20 @@ import {
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
Autocomplete,
|
||||
Input,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download, FileText, History, Upload } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
|
||||
import { downloadContainerReturnTemplate } from "@/components/warehouses/container-return-excel";
|
||||
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
@@ -35,13 +40,16 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
|
||||
import type {
|
||||
EmptyContainerReturn,
|
||||
EmptyContainerReturnStatus,
|
||||
EmptyContainerSize,
|
||||
EmptyReturnBooking,
|
||||
PlannedEmptyReturn,
|
||||
} from "@/types/importOperations";
|
||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { formatDateTime, localNowForInput } from "@/lib/format";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
@@ -72,6 +80,32 @@ const RETURNED_BY_SERIES = [
|
||||
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A scheduled request seen as the booking shape `BookingEmptyReturnModal`
|
||||
* takes, so confirming an arrival runs through exactly the same recording
|
||||
* path as any other empty return.
|
||||
*/
|
||||
const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({
|
||||
bookingId: planned.bookingId,
|
||||
bookingReference: planned.bookingReference ?? planned.bookingId,
|
||||
bookingStatus: "SCHEDULED_RETURN",
|
||||
equipmentReturn: "REQUESTED",
|
||||
customerId: planned.companyId,
|
||||
companyName: planned.companyName,
|
||||
containers: planned.containers.map((container) => ({
|
||||
key: `${planned.requestId}-${container.containerNumber}`,
|
||||
unitId: `${planned.requestId}-${container.containerNumber}`,
|
||||
containerNumber: container.containerNumber,
|
||||
containerSize: null,
|
||||
containerType: null,
|
||||
returnId: container.returnId,
|
||||
returnStatus: null,
|
||||
})),
|
||||
expectedCount: planned.containers.length,
|
||||
recordedCount: planned.containers.filter((c) => c.returnId).length,
|
||||
pendingCount: planned.containers.filter((c) => !c.returnId).length,
|
||||
});
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
@@ -102,9 +136,13 @@ export default function ContainerReturnsPage() {
|
||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [historyRow, setHistoryRow] = useState<any | null>(null);
|
||||
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
|
||||
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
|
||||
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
|
||||
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
|
||||
const [documentBusyId, setDocumentBusyId] = useState<string | null>(null);
|
||||
|
||||
const viewInterchangeDocument = async (ret: EmptyContainerReturn) => {
|
||||
@@ -140,6 +178,24 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Bookings that ship WITH empty-container return and still owe empties. This
|
||||
// list stands on the booking's own return flags, so it does not wait for the
|
||||
// box to reach a warehouse or for a last-mile truck to be assigned — the
|
||||
// queue below still covers that path.
|
||||
const emptyReturnBookingsQuery = useQuery({
|
||||
queryKey: ["empty-return-bookings"],
|
||||
queryFn: () => importOperationsService.listEmptyReturnBookings(),
|
||||
});
|
||||
const emptyReturnBookings = emptyReturnBookingsQuery.data ?? [];
|
||||
|
||||
// Requests the customer already paid for and booked a truck against — the
|
||||
// warehouse confirms these on arrival, which is what records the containers.
|
||||
const plannedReturnsQuery = useQuery({
|
||||
queryKey: ["planned-empty-returns"],
|
||||
queryFn: () => emptyReturnRequestsService.planned(),
|
||||
});
|
||||
const plannedReturns = plannedReturnsQuery.data ?? [];
|
||||
|
||||
const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[];
|
||||
const containerReturnsQuery = useQuery({
|
||||
queryKey: ["container-returns", bookingIds],
|
||||
@@ -281,17 +337,24 @@ export default function ContainerReturnsPage() {
|
||||
searchKeys: ["bookingRef", "companyName"],
|
||||
});
|
||||
|
||||
const bookingReturnControls = useListControls(emptyReturnBookings, {
|
||||
searchKeys: ["bookingReference", "companyName", "bookingStatus"],
|
||||
});
|
||||
|
||||
const createReturnsMutation = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
trucks: Array<{
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
companyName?: string;
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
containerSize?: EmptyContainerSize;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
yard?: string;
|
||||
zone?: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
}>;
|
||||
@@ -306,7 +369,10 @@ export default function ContainerReturnsPage() {
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
companyName: truck.companyName,
|
||||
facility: container.warehouse,
|
||||
yard: container.yard,
|
||||
zone: container.zone,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
returnedBy: truck.returnType,
|
||||
@@ -319,8 +385,14 @@ export default function ContainerReturnsPage() {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-bookings"] });
|
||||
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
|
||||
setReturnModalOpen(false);
|
||||
setStandaloneModalOpen(false);
|
||||
setActiveKey(null);
|
||||
setEmptyReturnBooking(null);
|
||||
setArrivingReturn(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
@@ -372,9 +444,15 @@ export default function ContainerReturnsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bookingRef",
|
||||
header: "Booking Ref",
|
||||
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||
id: "company",
|
||||
header: "Company",
|
||||
// One identity column: who the box belongs to, and the booking it came
|
||||
// back on. A standalone return has no booking, so only the name shows.
|
||||
cell: ({ row }) => {
|
||||
const { companyName, bookingReference } = row.original;
|
||||
if (!companyName) return bookingReference || "—";
|
||||
return bookingReference ? `${companyName} (${bookingReference})` : companyName;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "returnedBy",
|
||||
@@ -390,9 +468,8 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
{
|
||||
id: "returnDate",
|
||||
header: "Returned Date",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
|
||||
header: "Returned Date & Time",
|
||||
cell: ({ row }) => formatDateTime(row.original.returnDate),
|
||||
},
|
||||
{
|
||||
id: "facility",
|
||||
@@ -510,11 +587,259 @@ export default function ContainerReturnsPage() {
|
||||
{ label: "Customer Self-Haul", value: "customer" },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>
|
||||
Record Return
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadContainerReturnTemplate()}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>Record Return</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{plannedReturns.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>Planned Empty Returns</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customers who paid for an empty return and booked a truck. Confirm the arrival
|
||||
to record the containers.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" size="lg" color="orange">
|
||||
{plannedReturns.length} expected
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Return Date</Table.Th>
|
||||
<Table.Th>Truck</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{plannedReturns.map((planned) => {
|
||||
const outstanding = planned.containers.filter((c) => !c.returnId);
|
||||
return (
|
||||
<Table.Tr key={planned.requestId}>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{planned.truckDriverName ?? "—"}
|
||||
{planned.truckType ? ` · ${planned.truckType}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={2}>
|
||||
<Badge color={outstanding.length ? "orange" : "edr-green"}>
|
||||
{outstanding.length} of {planned.containers.length} outstanding
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{planned.containers.map((c) => c.containerNumber).join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={outstanding.length === 0}
|
||||
onClick={() => setArrivingReturn(planned)}
|
||||
>
|
||||
Confirm Arrival
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>Bookings With Empty Container Return</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Bookings that ship with equipment return and still owe empties. Open one to pick
|
||||
the containers coming back, then record the return for that booking.
|
||||
</Text>
|
||||
</div>
|
||||
<Badge variant="light" size="lg">
|
||||
{emptyReturnBookings.length} booking{emptyReturnBookings.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={bookingReturnControls.search}
|
||||
onSearchChange={bookingReturnControls.setSearch}
|
||||
searchPlaceholder="Search booking, company, status…"
|
||||
dateFrom={bookingReturnControls.dateFrom}
|
||||
onDateFromChange={bookingReturnControls.setDateFrom}
|
||||
dateTo={bookingReturnControls.dateTo}
|
||||
onDateToChange={bookingReturnControls.setDateTo}
|
||||
showDateRange={false}
|
||||
hasFilters={bookingReturnControls.hasFilters}
|
||||
onReset={bookingReturnControls.reset}
|
||||
/>
|
||||
|
||||
{emptyReturnBookingsQuery.isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : emptyReturnBookingsQuery.isError ? (
|
||||
<Alert color="red">
|
||||
Could not load bookings with empty container return.{" "}
|
||||
{extractErrorMessage(emptyReturnBookingsQuery.error)}
|
||||
</Alert>
|
||||
) : bookingReturnControls.pagedRows.length === 0 ? (
|
||||
<Alert color="gray">
|
||||
No booking is waiting on an empty container return.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Booking Status</Table.Th>
|
||||
<Table.Th>Containers To Return</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookingReturnControls.pagedRows.map((booking) => {
|
||||
const isOpen = expandedBooking === booking.bookingId;
|
||||
return (
|
||||
<Fragment key={booking.bookingId}>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() =>
|
||||
setExpandedBooking(isOpen ? null : booking.bookingId)
|
||||
}
|
||||
title={isOpen ? "Hide containers" : "Show containers"}
|
||||
>
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{booking.bookingReference}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.companyName ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{booking.bookingStatus.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Badge color="orange">{booking.pendingCount} pending</Badge>
|
||||
{booking.recordedCount > 0 && (
|
||||
<Badge color="edr-green" variant="light">
|
||||
{booking.recordedCount} recorded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setEmptyReturnBooking(booking)}
|
||||
>
|
||||
Empty Container Return
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Return Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.containers.map((container) => (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>{container.containerNumber}</Table.Td>
|
||||
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
|
||||
<Table.Td>{container.containerType ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{container.returnStatus ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
{RETURN_STATUS_LABEL[container.returnStatus] ??
|
||||
container.returnStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="orange" variant="light">
|
||||
Awaiting return
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<RuleEngineListFooter
|
||||
pagination={bookingReturnControls.pagination}
|
||||
pageCount={bookingReturnControls.pageCount}
|
||||
totalCount={bookingReturnControls.totalCount}
|
||||
itemLabel="bookings"
|
||||
onPaginationChange={bookingReturnControls.setPagination}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
@@ -688,6 +1013,37 @@ export default function ContainerReturnsPage() {
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
<BookingEmptyReturnModal
|
||||
booking={emptyReturnBooking}
|
||||
onClose={() => setEmptyReturnBooking(null)}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* A scheduled return arrives on the customer's own truck, so the modal
|
||||
opens pre-set to self-haul with that truck already noted. */}
|
||||
<BookingEmptyReturnModal
|
||||
title="Confirm Empty Return Arrival"
|
||||
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
|
||||
onClose={() => setArrivingReturn(null)}
|
||||
onSubmit={(payload) => createReturnsMutation.mutate(payload)}
|
||||
loading={createReturnsMutation.isPending}
|
||||
defaultReturnedBy="CUSTOMER"
|
||||
defaultHandoverNote={
|
||||
arrivingReturn
|
||||
? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${
|
||||
arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<BulkContainerReturnModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
|
||||
/>
|
||||
|
||||
<ExportTrainAllocationModal
|
||||
row={allocateRow}
|
||||
onClose={() => setAllocateRow(null)}
|
||||
@@ -848,7 +1204,7 @@ interface ContainerReturnModalProps {
|
||||
|
||||
function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: ContainerReturnModalProps) {
|
||||
const [selectedContainers, setSelectedContainers] = useState<string[]>([]);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
@@ -940,13 +1296,20 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
@@ -982,6 +1345,305 @@ function ContainerReturnModal({ opened, onClose, group, onSubmit, loading }: Con
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
interface BookingEmptyReturnModalProps {
|
||||
booking: EmptyReturnBooking | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: any) => void;
|
||||
loading: boolean;
|
||||
/** Pre-set for a scheduled return, where the truck type is already known. */
|
||||
defaultReturnedBy?: "EDR" | "CUSTOMER";
|
||||
/** Pre-set for a scheduled return — the truck the customer told us about. */
|
||||
defaultHandoverNote?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the empty return for ONE booking: tick the containers coming back,
|
||||
* say where they landed, and every tick becomes an empty container return on
|
||||
* that booking. Containers whose return is already recorded stay visible but
|
||||
* cannot be ticked again. A legacy booking that never captured container
|
||||
* numbers shows numberless slots — the number is typed here instead.
|
||||
*/
|
||||
function BookingEmptyReturnModal({
|
||||
booking,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
defaultReturnedBy,
|
||||
defaultHandoverNote,
|
||||
title = "Empty Container Return",
|
||||
}: BookingEmptyReturnModalProps) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [condition, setCondition] = useState<string>("");
|
||||
const [handoverNote, setHandoverNote] = useState<string>("");
|
||||
|
||||
const bookingId = booking?.bookingId ?? null;
|
||||
|
||||
// A fresh booking starts from a clean form — never inherit the last one's
|
||||
// ticks, typed numbers, or placement.
|
||||
useEffect(() => {
|
||||
setSelected([]);
|
||||
setReturnedBy(defaultReturnedBy ?? null);
|
||||
setReturnDate(localNowForInput());
|
||||
setWarehouse(null);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
setCondition("");
|
||||
setHandoverNote(defaultHandoverNote ?? "");
|
||||
}, [bookingId, defaultReturnedBy, defaultHandoverNote]);
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: async () => {
|
||||
return await warehouseService.list({});
|
||||
},
|
||||
});
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouse]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoneId(null);
|
||||
}, [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh: any) => ({
|
||||
value: wh.id,
|
||||
label: `${wh.name} ${wh.code ? `(${wh.code})` : ""}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const pending = (booking?.containers ?? []).filter((container) => !container.returnId);
|
||||
|
||||
const toggle = (key: string, checked: boolean) =>
|
||||
setSelected((current) => (checked ? [...current, key] : current.filter((k) => k !== key)));
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!booking || !selected.length || !warehouse || !returnedBy) return;
|
||||
|
||||
const selectedWarehouse = Array.isArray(warehouses)
|
||||
? warehouses.find((wh: any) => wh.id === warehouse)
|
||||
: null;
|
||||
const selectedYard = yards?.find((y) => y.id === yardId);
|
||||
const selectedZone = zones?.find((z) => z.id === zoneId);
|
||||
|
||||
const containers = pending
|
||||
.filter((container) => selected.includes(container.key))
|
||||
.map((container) => ({
|
||||
containerNumber: container.containerNumber,
|
||||
// The booking records "20ft"/"40ft"; the wagon rule only needs the number.
|
||||
containerSize: container.containerSize?.includes("40")
|
||||
? ("40" as const)
|
||||
: container.containerSize?.includes("20")
|
||||
? ("20" as const)
|
||||
: undefined,
|
||||
returnDate,
|
||||
warehouse: selectedWarehouse?.name || warehouse,
|
||||
yard: selectedYard?.name,
|
||||
zone: selectedZone?.name,
|
||||
condition: condition || undefined,
|
||||
handoverNote: handoverNote || undefined,
|
||||
}));
|
||||
|
||||
onSubmit({
|
||||
trucks: [
|
||||
{
|
||||
bookingId: booking.bookingId,
|
||||
customerId: booking.customerId,
|
||||
companyName: booking.companyName ?? undefined,
|
||||
returnType: returnedBy,
|
||||
containers,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
|
||||
{booking && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{booking.bookingReference}</Text>
|
||||
{booking.companyName && <Text c="dimmed">{booking.companyName}</Text>}
|
||||
<Badge size="sm" variant="light">
|
||||
{booking.bookingStatus.replaceAll("_", " ")}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
Containers to return
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === pending.length ? [] : pending.map((c) => c.key),
|
||||
)
|
||||
}
|
||||
>
|
||||
{selected.length === pending.length ? "Clear all" : "Select all"}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th w={90}>Size</Table.Th>
|
||||
<Table.Th w={150}>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.containers.map((container) => {
|
||||
const recorded = Boolean(container.returnId);
|
||||
return (
|
||||
<Table.Tr key={container.key}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.includes(container.key)}
|
||||
disabled={recorded}
|
||||
onChange={(e) => toggle(container.key, e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{container.containerNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{container.containerSize ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{recorded ? (
|
||||
<Badge size="sm" color="edr-green" variant="light">
|
||||
{(container.returnStatus &&
|
||||
RETURN_STATUS_LABEL[container.returnStatus]) ??
|
||||
"Recorded"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" color="orange" variant="light">
|
||||
Awaiting return
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(val) => setReturnedBy(val as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Last Mile" },
|
||||
{ value: "CUSTOMER", label: "Customer Self-Haul" },
|
||||
]}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Return Warehouse"
|
||||
placeholder="Select warehouse for container return"
|
||||
value={warehouse}
|
||||
onChange={setWarehouse}
|
||||
data={warehouseOptions}
|
||||
required
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouse ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouse}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
placeholder="Damage, residue, or cleanliness notes"
|
||||
value={condition}
|
||||
onChange={(e) => setCondition(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Handover Note"
|
||||
placeholder="Consignee, trucker, or authorization notes"
|
||||
value={handoverNote}
|
||||
onChange={(e) => setHandoverNote(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selected.length || !warehouse || !returnedBy}
|
||||
loading={loading}
|
||||
>
|
||||
Record Empty Return ({selected.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface StandaloneReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -991,9 +1653,10 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [company, setCompany] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
const [returnDate, setReturnDate] = useState<string>(localNowForInput());
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
@@ -1009,6 +1672,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
|
||||
const companies = useCompanyOptions();
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
@@ -1047,7 +1711,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
trucks: [
|
||||
{
|
||||
bookingId: null,
|
||||
customerId: null,
|
||||
customerId: company ? (companies.resolveId(company) ?? null) : null,
|
||||
companyName: company || undefined,
|
||||
returnType: returnedBy,
|
||||
containers: [
|
||||
{
|
||||
@@ -1066,9 +1731,10 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setCompany("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
setReturnDate(localNowForInput());
|
||||
setWarehouse(null);
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
@@ -1092,6 +1758,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
@@ -1145,13 +1821,20 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
searchable
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ccc" }}
|
||||
required
|
||||
/>
|
||||
<Input.Wrapper label="Returned Date & Time" required>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
<Textarea
|
||||
label="Condition"
|
||||
|
||||
@@ -0,0 +1,497 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import { extractErrorMessage } from "@/components/warehouses/options";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service";
|
||||
import type {
|
||||
EmptyReturnRequest,
|
||||
EmptyReturnRequestStatus,
|
||||
} from "@/types/importOperations";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
|
||||
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
|
||||
SUBMITTED: { label: "Awaiting review", color: "orange" },
|
||||
APPROVED: { label: "Awaiting payment", color: "yellow" },
|
||||
REJECTED: { label: "Rejected", color: "red" },
|
||||
PAID: { label: "Paid — awaiting date", color: "blue" },
|
||||
SCHEDULED: { label: "Scheduled", color: "edr-green" },
|
||||
COMPLETED: { label: "Returned", color: "gray" },
|
||||
CANCELLED: { label: "Cancelled", color: "gray" },
|
||||
};
|
||||
|
||||
const money = (amount: number | null | undefined, currency: string | null | undefined) =>
|
||||
amount == null
|
||||
? "—"
|
||||
: `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim();
|
||||
|
||||
/**
|
||||
* The queue for customer-initiated empty container returns: a booking sold
|
||||
* WITHOUT the return service, whose customer now wants to send the empties
|
||||
* back. Staff price and approve — which invoices the customer — or reject with
|
||||
* a reason. Everything after payment (date, truck) happens in the portal, and
|
||||
* the containers themselves are recorded on Container Returns.
|
||||
*/
|
||||
export default function EmptyReturnRequestsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
// Anyone who runs container returns can watch the queue; pricing and
|
||||
// approving is its own permission, so show the buttons disabled rather than
|
||||
// letting them fire into a 403.
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
|
||||
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
|
||||
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ["empty-return-requests"],
|
||||
queryFn: () => emptyReturnRequestsService.list(),
|
||||
});
|
||||
|
||||
const requests = useMemo(() => {
|
||||
const rows = requestsQuery.data ?? [];
|
||||
return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows;
|
||||
}, [requestsQuery.data, statusFilter]);
|
||||
|
||||
const controls = useListControls(requests, {
|
||||
dateKey: "submittedAt",
|
||||
searchValue: (row) =>
|
||||
`${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`,
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: ["empty-return-requests"] });
|
||||
qc.invalidateQueries({ queryKey: ["planned-empty-returns"] });
|
||||
};
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) =>
|
||||
emptyReturnRequestsService.approve(id, { unitAmount }),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Approved — invoice sent to the customer" });
|
||||
invalidate();
|
||||
setApproving(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not approve the request",
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
|
||||
emptyReturnRequestsService.reject(id, reason),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Request rejected" });
|
||||
invalidate();
|
||||
setRejecting(null);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not reject the request",
|
||||
description: extractErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ColumnDef<EmptyReturnRequest>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
header: "Booking",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.bookingReference ?? row.original.bookingId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.companyName ?? "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "containers",
|
||||
header: "Containers",
|
||||
cell: ({ row }) => (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm">{row.original.containerCount}</Badge>
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "submittedAt",
|
||||
header: "Requested",
|
||||
cell: ({ row }) => formatDateTime(row.original.submittedAt),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
cell: ({ row }) =>
|
||||
row.original.quotedTotalAmount == null ? (
|
||||
"—"
|
||||
) : (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{money(row.original.quotedTotalAmount, row.original.currency)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
|
||||
</Text>
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "return",
|
||||
header: "Return",
|
||||
cell: ({ row }) =>
|
||||
row.original.requestedReturnDate ? (
|
||||
<Stack gap={2}>
|
||||
<Text size="sm">{row.original.requestedReturnDate}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.truckPlateNumber ?? "—"}
|
||||
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
|
||||
{meta?.label ?? row.original.status}
|
||||
</Badge>
|
||||
{row.original.rejectionReason && (
|
||||
<Text size="xs" c="dimmed" lineClamp={2}>
|
||||
{row.original.rejectionReason}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) =>
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setRejecting(row.original)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setApproving(row.original)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="right">
|
||||
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Empty Return Requests"
|
||||
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
|
||||
/>
|
||||
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>
|
||||
Requests
|
||||
{pending > 0 && (
|
||||
<Badge ml="sm" color="orange" variant="light">
|
||||
{pending} awaiting review
|
||||
</Badge>
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<ListControls
|
||||
search={controls.search}
|
||||
onSearchChange={controls.setSearch}
|
||||
searchPlaceholder="Search booking, company, container…"
|
||||
dateFrom={controls.dateFrom}
|
||||
onDateFromChange={controls.setDateFrom}
|
||||
dateTo={controls.dateTo}
|
||||
onDateToChange={controls.setDateTo}
|
||||
dateLabel="Requested"
|
||||
hasFilters={controls.hasFilters || Boolean(statusFilter)}
|
||||
onReset={() => {
|
||||
controls.reset();
|
||||
setStatusFilter(null);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
data={Object.entries(STATUS_META).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}))}
|
||||
clearable
|
||||
w={220}
|
||||
/>
|
||||
</ListControls>
|
||||
|
||||
{requestsQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : requestsQuery.isError ? (
|
||||
<Alert color="red">
|
||||
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
|
||||
</Alert>
|
||||
) : controls.pagedRows.length === 0 ? (
|
||||
<Alert color="gray">No empty return requests.</Alert>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={controls.pagedRows}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...controls.tableProps}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<ApproveModal
|
||||
request={approving}
|
||||
onClose={() => setApproving(null)}
|
||||
onApprove={(unitAmount) =>
|
||||
approving && approveMutation.mutate({ id: approving.id, unitAmount })
|
||||
}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={!!rejecting}
|
||||
onClose={() => setRejecting(null)}
|
||||
title="Reject empty return request"
|
||||
size="md"
|
||||
>
|
||||
{rejecting && (
|
||||
<RejectForm
|
||||
request={rejecting}
|
||||
loading={rejectMutation.isPending}
|
||||
onCancel={() => setRejecting(null)}
|
||||
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pricing step. The per-container price is prefilled from the booking's
|
||||
* route WITH_RETURN rate; the reviewer can override it before approving, and
|
||||
* approving is what issues the customer's invoice.
|
||||
*/
|
||||
function ApproveModal({
|
||||
request,
|
||||
onClose,
|
||||
onApprove,
|
||||
loading,
|
||||
}: {
|
||||
request: EmptyReturnRequest | null;
|
||||
onClose: () => void;
|
||||
onApprove: (unitAmount?: number) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [unitAmount, setUnitAmount] = useState<number | "">("");
|
||||
|
||||
const quoteQuery = useQuery({
|
||||
queryKey: ["empty-return-quote", request?.bookingId],
|
||||
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
|
||||
enabled: Boolean(request),
|
||||
});
|
||||
|
||||
// Prefill from the route rate as soon as it lands, and start clean whenever
|
||||
// a different request is opened.
|
||||
useEffect(() => {
|
||||
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
|
||||
}, [quoteQuery.data?.unitAmount, request?.id]);
|
||||
|
||||
const count = request?.containerCount ?? 0;
|
||||
const total = typeof unitAmount === "number" ? unitAmount * count : null;
|
||||
const currency = quoteQuery.data?.currency ?? "ETB";
|
||||
|
||||
return (
|
||||
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
|
||||
{request && (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
|
||||
<Text c="dimmed">{request.companyName ?? "—"}</Text>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text size="sm" fw={600} mb={4}>
|
||||
Containers coming back
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{request.containerNumbers.join(", ")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{quoteQuery.isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
{quoteQuery.data?.unavailableReason && (
|
||||
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
|
||||
)}
|
||||
|
||||
<NumberInput
|
||||
label={`Price per container (${currency})`}
|
||||
description={
|
||||
quoteQuery.data?.sourceRateUsd
|
||||
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
|
||||
: "No route rate found — enter the amount to bill."
|
||||
}
|
||||
value={unitAmount}
|
||||
onChange={(value) =>
|
||||
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
|
||||
}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
required
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{count} container{count === 1 ? "" : "s"} ×{" "}
|
||||
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
|
||||
</Text>
|
||||
<Text size="lg" fw={700} ta="right">
|
||||
{total == null
|
||||
? "—"
|
||||
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
|
||||
</Text>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
Approving issues this invoice to the customer. They pay it in the portal, then
|
||||
choose the return date and give the truck details.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
|
||||
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
|
||||
loading={loading}
|
||||
>
|
||||
Approve & invoice
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function RejectForm({
|
||||
request,
|
||||
loading,
|
||||
onCancel,
|
||||
onReject,
|
||||
}: {
|
||||
request: EmptyReturnRequest;
|
||||
loading: boolean;
|
||||
onCancel: () => void;
|
||||
onReject: (reason: string) => void;
|
||||
}) {
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{request.bookingReference ?? request.bookingId} — {request.containerCount} container
|
||||
{request.containerCount === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
description="Shown to the customer."
|
||||
placeholder="Why this return cannot be accepted"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.currentTarget.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onCancel} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{/* Work the cargo right here while the train is at the yard. */}
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.status === "PAID" && (
|
||||
{r.trainScheduleId && atOrigin(r) && isWaiting(r) && r.paymentStatus === "PAID" && (
|
||||
<Tooltip label={canLoad ? undefined : "You don't have permission to load cargo"} disabled={canLoad}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -18,7 +18,9 @@ import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
|
||||
const isPaid = (item: WarehouseInventoryItem) => item.booking?.status === 'PAID';
|
||||
// "Paid" is the booking's PAYMENT status only — never booking.status === 'PAID'.
|
||||
const isPaid = (item: WarehouseInventoryItem) =>
|
||||
(item.booking?.paymentStatus ?? item.bookingPaymentStatus) === 'PAID';
|
||||
|
||||
/**
|
||||
* Loading Queue — manage inventory through the loading workflow.
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Input,
|
||||
List,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { Download, Upload } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
|
||||
import {
|
||||
downloadFullContainerTemplate,
|
||||
parseFullContainerExcel,
|
||||
type ParsedFullContainerRow,
|
||||
} from "@/components/warehouses/full-container-excel";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { containerTypesService } from "@/services/container-types.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { RegisterBacklogContainerPayload } from "@/types/warehouse";
|
||||
|
||||
/** `YYYY-MM-DD` for today — the latest arrival a backlog box can claim. */
|
||||
function todayForInput(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded containers that have been sitting in a yard since before the system
|
||||
* knew about them. Registering one records its true arrival date without
|
||||
* billing storage for the history — the server flags the row so the fee engine
|
||||
* skips it entirely.
|
||||
*/
|
||||
export default function RegisterFullContainersPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const companies = useCompanyOptions();
|
||||
const [mode, setMode] = useState<"single" | "bulk">("single");
|
||||
|
||||
// Location + owner, shared by both modes. In bulk they are the defaults that
|
||||
// fill any blank cell in the sheet.
|
||||
const [company, setCompany] = useState("");
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [arrivedAt, setArrivedAt] = useState(todayForInput());
|
||||
const [containerTypeId, setContainerTypeId] = useState<string | null>(null);
|
||||
|
||||
// Single-container fields.
|
||||
const [containerNumber, setContainerNumber] = useState("");
|
||||
const [sealNumber, setSealNumber] = useState("");
|
||||
const [weight, setWeight] = useState<number | string>("");
|
||||
const [notes, setNotes] = useState("");
|
||||
|
||||
// Bulk fields.
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [rows, setRows] = useState<ParsedFullContainerRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: () => warehouseService.list({}),
|
||||
});
|
||||
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
|
||||
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
const { data: containerTypes = [] } = useQuery({
|
||||
queryKey: ["container-types-active"],
|
||||
queryFn: () => containerTypesService.getContainerTypes(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouseId]);
|
||||
useEffect(() => setZoneId(null), [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
|
||||
: [];
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
const containerTypeOptions = (containerTypes as any[]).map((ct) => ({
|
||||
value: ct.id,
|
||||
label: ct.label ? `${ct.label} (${ct.code})` : ct.code,
|
||||
}));
|
||||
|
||||
const locationReady = Boolean(warehouseId && yardId && zoneId);
|
||||
|
||||
const basePayload = useMemo(
|
||||
() => ({
|
||||
warehouseId: warehouseId ?? "",
|
||||
yardId: yardId ?? "",
|
||||
zoneId: zoneId ?? "",
|
||||
companyId: company ? companies.resolveId(company) : undefined,
|
||||
companyName: company || undefined,
|
||||
}),
|
||||
[warehouseId, yardId, zoneId, company, companies],
|
||||
);
|
||||
|
||||
const onSaved = (count: number) => {
|
||||
toast({ title: `${count} container${count === 1 ? "" : "s"} registered` });
|
||||
qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
|
||||
};
|
||||
|
||||
const singleMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
warehouseService.registerBacklogContainer({
|
||||
...basePayload,
|
||||
containerNumber: containerNumber.trim().toUpperCase(),
|
||||
containerTypeId: containerTypeId ?? "",
|
||||
arrivedAt: new Date(arrivedAt).toISOString(),
|
||||
sealNumber: sealNumber.trim() || undefined,
|
||||
weight: weight === "" ? undefined : Number(weight),
|
||||
notes: notes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSaved(1);
|
||||
setContainerNumber("");
|
||||
setSealNumber("");
|
||||
setWeight("");
|
||||
setNotes("");
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not register container",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
}),
|
||||
});
|
||||
|
||||
// Row cell wins; the fields above the file fill the blanks.
|
||||
const toPayload = (row: ParsedFullContainerRow): RegisterBacklogContainerPayload => ({
|
||||
...basePayload,
|
||||
companyName: row.companyName || basePayload.companyName,
|
||||
companyId: row.companyName ? companies.resolveId(row.companyName) : basePayload.companyId,
|
||||
containerNumber: row.containerNumber,
|
||||
containerTypeId: containerTypeId ?? "",
|
||||
arrivedAt: row.arrivedAt ?? new Date(arrivedAt).toISOString(),
|
||||
sealNumber: row.sealNumber || undefined,
|
||||
weight: row.weight === "" ? undefined : Number(row.weight),
|
||||
notes: row.notes || undefined,
|
||||
});
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: () => warehouseService.registerBacklogContainersBulk(rows.map(toPayload)),
|
||||
onSuccess: (response) => {
|
||||
onSaved(response.data?.length ?? rows.length);
|
||||
setFile(null);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Bulk registration failed",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
}),
|
||||
});
|
||||
|
||||
const handleFile = async (next: File | null) => {
|
||||
setFile(next);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
if (!next) return;
|
||||
const result = await parseFullContainerExcel(next);
|
||||
setRows(result.rows);
|
||||
setParseErrors(result.errors);
|
||||
};
|
||||
|
||||
const singleReady =
|
||||
locationReady && Boolean(containerTypeId) && containerNumber.trim().length > 0 && Boolean(arrivedAt);
|
||||
const bulkReady = locationReady && Boolean(containerTypeId) && rows.length > 0;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Register Full Containers"
|
||||
subtitle="Loaded containers already in the yard but not yet on the system"
|
||||
/>
|
||||
|
||||
<Group mb="lg" justify="space-between">
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "single" | "bulk")}
|
||||
data={[
|
||||
{ label: "Single Container", value: "single" },
|
||||
{ label: "Bulk Upload", value: "bulk" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadFullContainerTemplate()}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Alert color="blue" mb="lg" title="Backlog registrations are not billed">
|
||||
The arrival date you enter is kept as the real record of how long the box has been here,
|
||||
but no storage or demurrage accrues against it.
|
||||
</Alert>
|
||||
|
||||
<Card withBorder radius="lg" p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start">
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
<Select
|
||||
label="Container Type"
|
||||
placeholder="Select container type"
|
||||
value={containerTypeId}
|
||||
onChange={setContainerTypeId}
|
||||
data={containerTypeOptions}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Select warehouse"
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
data={warehouseOptions}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouseId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Input.Wrapper
|
||||
label="Arrival Date"
|
||||
description={
|
||||
mode === "bulk"
|
||||
? "Used for any row whose sheet cell is blank"
|
||||
: "When the container actually arrived in the yard"
|
||||
}
|
||||
required
|
||||
>
|
||||
<input
|
||||
type="date"
|
||||
value={arrivedAt}
|
||||
max={todayForInput()}
|
||||
onChange={(e) => setArrivedAt(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid #ced4da",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
{mode === "single" ? (
|
||||
<>
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
label="Container Number"
|
||||
placeholder="e.g., TEMU1234567"
|
||||
value={containerNumber}
|
||||
onChange={(e) => setContainerNumber(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Seal Number"
|
||||
placeholder="Optional"
|
||||
value={sealNumber}
|
||||
onChange={(e) => setSealNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Weight (Tons)"
|
||||
placeholder="Optional"
|
||||
value={weight}
|
||||
onChange={setWeight}
|
||||
min={0}
|
||||
decimalScale={3}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Where it came from, condition, anything worth recording"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
onClick={() => singleMutation.mutate()}
|
||||
disabled={!singleReady}
|
||||
loading={singleMutation.isPending}
|
||||
>
|
||||
Register Container
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input.Wrapper label="Excel file" description="One row per container">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={(e) => void handleFile(e.target.files?.[0] ?? null)}
|
||||
style={{ display: "block", padding: "8px 0" }}
|
||||
/>
|
||||
</Input.Wrapper>
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was registered`}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<List size="sm">
|
||||
{parseErrors.map((err) => (
|
||||
<List.Item key={err}>{err}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Preview
|
||||
</Text>
|
||||
<Badge size="sm">{rows.length} containers</Badge>
|
||||
{file && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{file.name}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={320}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Arrived</Table.Th>
|
||||
<Table.Th>Seal</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const payload = toPayload(row);
|
||||
return (
|
||||
<Table.Tr key={row.containerNumber}>
|
||||
<Table.Td>{payload.containerNumber}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">{payload.companyName || "—"}</Text>
|
||||
{payload.companyName && !payload.companyId && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{new Date(payload.arrivedAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.sealNumber ?? "—"}</Table.Td>
|
||||
<Table.Td>{payload.weight ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => bulkMutation.mutate()}
|
||||
disabled={!bulkReady}
|
||||
loading={bulkMutation.isPending}
|
||||
>
|
||||
Register {rows.length > 0 ? `${rows.length} containers` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { ArrowLeft, Boxes, LayoutGrid, Package, Pencil, Plus } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Boxes, Layers, LayoutGrid, Package, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -23,16 +23,27 @@ import {
|
||||
InventoryWorkbench,
|
||||
WarehouseStatusBadge,
|
||||
WarehouseTypeBadge,
|
||||
ZoneContentsModal,
|
||||
type ZoneRef,
|
||||
ZoneLayoutModal,
|
||||
ZoneOccupancyHeatmap,
|
||||
formatCapacity,
|
||||
humanizeEnum,
|
||||
} from '@/components/warehouses';
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import { api } from '@/services/api';
|
||||
import type { WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canDeleteYard = hasPermission(user, FREIGHT_PERMS.warehouseYards.delete);
|
||||
const canDeleteZone = hasPermission(user, FREIGHT_PERMS.warehouseZones.delete);
|
||||
|
||||
const { data: warehouse, isLoading } = useQuery(
|
||||
api.warehouses.getById.queryOptions({
|
||||
@@ -52,6 +63,8 @@ export default function WarehouseDetailPage() {
|
||||
const [zoneModalOpen, setZoneModalOpen] = useState(false);
|
||||
const [editingZone, setEditingZone] = useState<WarehouseZone | null>(null);
|
||||
const [selectedYardId, setSelectedYardId] = useState<string | null>(null);
|
||||
const [contentsZone, setContentsZone] = useState<ZoneRef | null>(null);
|
||||
const [layoutZone, setLayoutZone] = useState<ZoneRef | null>(null);
|
||||
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
@@ -72,6 +85,44 @@ export default function WarehouseDetailPage() {
|
||||
[yards],
|
||||
);
|
||||
|
||||
const deleteYard = useMutation(api.warehouses.deleteYard.mutationOptions());
|
||||
const deleteZone = useMutation(api.warehouses.deleteZone.mutationOptions());
|
||||
|
||||
// The API refuses a yard that still has zones (and a zone that still holds
|
||||
// inventory) with a 409 — surface that message rather than a bare failure.
|
||||
const removeYard = useCallback(
|
||||
(yard: WarehouseYard) => {
|
||||
if (!window.confirm(`Delete yard ${yard.code}? Its zones must be removed first.`)) return;
|
||||
deleteYard.mutate(
|
||||
{ id: yard.id },
|
||||
{
|
||||
onSuccess: () => toast({ title: `Yard ${yard.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
},
|
||||
);
|
||||
},
|
||||
[deleteYard, toast],
|
||||
);
|
||||
|
||||
const removeZone = useCallback(
|
||||
(zone: WarehouseZone) => {
|
||||
if (!window.confirm(`Delete zone ${zone.code}? It must be empty first.`)) return;
|
||||
deleteZone.mutate(
|
||||
{ id: zone.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: `Zone ${zone.code} deleted` });
|
||||
void zonesQuery.refetch();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
},
|
||||
);
|
||||
},
|
||||
[deleteZone, toast, zonesQuery],
|
||||
);
|
||||
|
||||
const yardColumns = useMemo<ColumnDef<WarehouseYard>[]>(
|
||||
() => [
|
||||
{ id: 'name', header: 'Name', cell: ({ row }) => row.original.name },
|
||||
@@ -98,20 +149,33 @@ export default function WarehouseDetailPage() {
|
||||
header: 'Actions',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingYard(row.original);
|
||||
setYardModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{canDeleteYard ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete"
|
||||
onClick={() => removeYard(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[canDeleteYard, removeYard],
|
||||
);
|
||||
|
||||
const zoneColumns = useMemo<ColumnDef<WarehouseZone>[]>(
|
||||
@@ -140,20 +204,41 @@ export default function WarehouseDetailPage() {
|
||||
header: 'Actions',
|
||||
meta: { headerClassName: 'text-right', cellClassName: 'text-right' },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Stack layout"
|
||||
onClick={() => setLayoutZone(row.original)}
|
||||
>
|
||||
<Layers size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
title="Edit"
|
||||
onClick={() => {
|
||||
setEditingZone(row.original);
|
||||
setZoneModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
{canDeleteZone ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
title="Delete"
|
||||
onClick={() => removeZone(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[canDeleteZone, removeZone],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
@@ -299,7 +384,10 @@ export default function WarehouseDetailPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<ZoneOccupancyHeatmap yardId={selectedYardId ?? undefined} />
|
||||
<ZoneOccupancyHeatmap
|
||||
yardId={selectedYardId ?? undefined}
|
||||
onZoneClick={(zone) => setContentsZone(zone)}
|
||||
/>
|
||||
|
||||
{!selectedYardId ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
@@ -309,6 +397,7 @@ export default function WarehouseDetailPage() {
|
||||
<DataTable
|
||||
columns={zoneColumns}
|
||||
data={zonesQuery.data ?? []}
|
||||
onRowClick={(zone) => setContentsZone(zone)}
|
||||
status={
|
||||
zonesQuery.isLoading ? 'loading' : zonesQuery.isError ? 'error' : 'success'
|
||||
}
|
||||
@@ -346,6 +435,18 @@ export default function WarehouseDetailPage() {
|
||||
yard={editingYard}
|
||||
/>
|
||||
)}
|
||||
<ZoneLayoutModal
|
||||
opened={Boolean(layoutZone)}
|
||||
onClose={() => setLayoutZone(null)}
|
||||
zone={layoutZone}
|
||||
/>
|
||||
|
||||
<ZoneContentsModal
|
||||
opened={Boolean(contentsZone)}
|
||||
onClose={() => setContentsZone(null)}
|
||||
zone={contentsZone}
|
||||
/>
|
||||
|
||||
{selectedYardId && (
|
||||
<CreateZoneModal
|
||||
opened={zoneModalOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Card, Center, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { useAuth } from '@/auth/useAuth';
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import {
|
||||
CreateWarehouseModal,
|
||||
@@ -17,11 +18,18 @@ import ListControls from '@/components/common/ListControls';
|
||||
// despite the ruleEngine path.
|
||||
import RuleEngineListFooter from '@/components/ruleEngine/RuleEngineListFooter';
|
||||
import { useListControls } from '@/hooks/useListControls';
|
||||
import { useWarehouses } from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useDeleteWarehouse, useWarehouses } from '@/hooks/useWarehouses';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions';
|
||||
import type { Warehouse, WarehouseFilter } from '@/types/warehouse';
|
||||
|
||||
export default function WarehouseListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const remove = useDeleteWarehouse();
|
||||
const canDelete = hasPermission(user, FREIGHT_PERMS.warehouses.delete);
|
||||
const [filter, setFilter] = useState<WarehouseFilter>({});
|
||||
const [view, setView] = useState<WarehouseView>('table');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -50,6 +58,15 @@ export default function WarehouseListPage() {
|
||||
setModalOpen(true);
|
||||
};
|
||||
const openDetail = (warehouse: Warehouse) => navigate(`/dashboard/warehouses/${warehouse.id}`);
|
||||
const handleDelete = (warehouse: Warehouse) => {
|
||||
if (!window.confirm(`Delete warehouse ${warehouse.code}? Yards must be removed first.`)) return;
|
||||
remove.mutate(warehouse.id, {
|
||||
onSuccess: () => toast({ title: `Warehouse ${warehouse.code} deleted` }),
|
||||
onError: (error) =>
|
||||
toast({ variant: 'destructive', title: 'Delete failed', description: extractErrorMessage(error) }),
|
||||
});
|
||||
};
|
||||
const onDelete = canDelete ? handleDelete : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -91,9 +108,19 @@ export default function WarehouseListPage() {
|
||||
) : (
|
||||
<>
|
||||
{view === 'table' ? (
|
||||
<WarehouseTable warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
<WarehouseTable
|
||||
warehouses={controls.pagedRows}
|
||||
onView={openDetail}
|
||||
onEdit={openEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
<WarehouseCardView warehouses={controls.pagedRows} onView={openDetail} onEdit={openEdit} />
|
||||
<WarehouseCardView
|
||||
warehouses={controls.pagedRows}
|
||||
onView={openDetail}
|
||||
onEdit={openEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
<RuleEngineListFooter
|
||||
pagination={controls.pagination}
|
||||
|
||||
Reference in New Issue
Block a user