mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -16,6 +16,10 @@ import {
|
||||
setCookie,
|
||||
} from "./cookies";
|
||||
import { applyTokens } from "./http";
|
||||
import {
|
||||
startTokenRefreshScheduler,
|
||||
stopTokenRefreshScheduler,
|
||||
} from "./refreshScheduler";
|
||||
import type { AuthTokens, AuthUser } from "./types";
|
||||
|
||||
interface LoginPayload {
|
||||
@@ -99,6 +103,18 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
void bootstrap();
|
||||
}, []);
|
||||
|
||||
// Keep the server session alive while a user is logged in. Runs after
|
||||
// login, MFA verification, and page-reload bootstrap alike.
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
stopTokenRefreshScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
startTokenRefreshScheduler();
|
||||
return stopTokenRefreshScheduler;
|
||||
}, [user]);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
|
||||
@@ -28,6 +28,30 @@ const applyTokens = ({ token, refreshToken }: AuthTokens) => {
|
||||
setCookie(REFRESH_TOKEN_COOKIE, refreshToken);
|
||||
};
|
||||
|
||||
/**
|
||||
* Single-flight token refresh: concurrent callers (the 401 interceptor and
|
||||
* the proactive scheduler) share one in-flight request so the refresh token
|
||||
* is only rotated once. Throws if no refresh token is stored or the server
|
||||
* rejects it — callers decide how to end the session.
|
||||
*/
|
||||
const refreshSessionTokens = async (): Promise<AuthTokens> => {
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
throw new Error("missing refresh token");
|
||||
}
|
||||
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
return tokens;
|
||||
};
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
|
||||
@@ -65,8 +89,7 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const refreshToken = getCookie(REFRESH_TOKEN_COOKIE);
|
||||
if (!refreshToken) {
|
||||
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||
clearSessionCookies();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -74,15 +97,7 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
refreshPromise ??= api
|
||||
.post<AuthTokens>("/auth/refresh-token", { refreshToken })
|
||||
.then((response) => response.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
const tokens = await refreshPromise;
|
||||
applyTokens(tokens);
|
||||
const tokens = await refreshSessionTokens();
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${tokens.token}`,
|
||||
@@ -97,4 +112,4 @@ api.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
export { api, applyTokens };
|
||||
export { api, applyTokens, refreshSessionTokens };
|
||||
|
||||
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
82
apps/edr-freight-web/backoffice/src/auth/refreshScheduler.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import {
|
||||
REFRESH_TOKEN_COOKIE,
|
||||
clearSessionCookies,
|
||||
getCookie,
|
||||
} from "./cookies";
|
||||
import { refreshSessionTokens } from "./http";
|
||||
|
||||
/**
|
||||
* Proactively refreshes the token pair on a fixed cadence so the server-side
|
||||
* session (a sliding 1-hour window, extended only by /auth/refresh-token) is
|
||||
* kept alive while the app is open. The 401 interceptor in http.ts remains
|
||||
* the reactive fallback; both share the same single-flight refresh call.
|
||||
*
|
||||
* The interval MUST stay well under the server session window (60 min).
|
||||
*/
|
||||
const DEFAULT_INTERVAL_MINUTES = 10;
|
||||
|
||||
const getIntervalMs = () => {
|
||||
const minutes = Number(import.meta.env.VITE_TOKEN_REFRESH_INTERVAL_MINUTES);
|
||||
return (
|
||||
(Number.isFinite(minutes) && minutes > 0
|
||||
? minutes
|
||||
: DEFAULT_INTERVAL_MINUTES) * 60_000
|
||||
);
|
||||
};
|
||||
|
||||
let timerId: number | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
|
||||
const refreshNow = async () => {
|
||||
if (!getCookie(REFRESH_TOKEN_COOKIE)) {
|
||||
// Logged out elsewhere; nothing to keep alive.
|
||||
stopTokenRefreshScheduler();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshSessionTokens();
|
||||
lastRefreshAt = Date.now();
|
||||
} catch (error) {
|
||||
// Network hiccups are retried on the next tick; only an explicit server
|
||||
// rejection means the session is dead.
|
||||
if (isAxiosError(error) && error.response) {
|
||||
stopTokenRefreshScheduler();
|
||||
clearSessionCookies();
|
||||
window.location.replace("/auth");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Browsers freeze timers in background tabs — a tab waking up past its
|
||||
* refresh deadline refreshes immediately instead of waiting a full interval.
|
||||
*/
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (Date.now() - lastRefreshAt >= getIntervalMs()) {
|
||||
void refreshNow();
|
||||
}
|
||||
};
|
||||
|
||||
export const startTokenRefreshScheduler = () => {
|
||||
stopTokenRefreshScheduler();
|
||||
|
||||
// Token age is unknown here (fresh login vs. hours-old page reload), so
|
||||
// refresh right away to extend the session window from "now".
|
||||
lastRefreshAt = 0;
|
||||
void refreshNow();
|
||||
|
||||
timerId = window.setInterval(() => void refreshNow(), getIntervalMs());
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
|
||||
export const stopTokenRefreshScheduler = () => {
|
||||
if (timerId !== null) {
|
||||
window.clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
|
||||
EXPIRED: "red",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "cyan",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from "react";
|
||||
import { Box, Center, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { FileText, FolderOpen } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
|
||||
interface LabeledFile {
|
||||
label: string;
|
||||
file: { id: string; name: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every document tied to a booking, in one tab: the customer/GL clearance
|
||||
* documents, the customs workflow files (declaration/duty/transit/Djibouti),
|
||||
* the duty-tax notice, and the final invoice + payment slip. All fetched from
|
||||
* the booking's clearance view (the only endpoint that surfaces booking files),
|
||||
* each with inline view + download.
|
||||
*/
|
||||
export function BookingDocumentsPanel({ bookingId }: { bookingId: string }) {
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading, isError } = useQuery({
|
||||
queryKey: ["clearance", bookingId],
|
||||
queryFn: () => bookingsService.getClearance(bookingId),
|
||||
});
|
||||
|
||||
const onDownload = (f: { id: string; name: string }) =>
|
||||
void downloadBookingFile(f.id, f.name);
|
||||
|
||||
// Uploaded customer + GL clearance documents (skip the not-yet-uploaded slots).
|
||||
const clearanceDocs = useMemo<
|
||||
Array<{ doc: Freight.ClearanceDocument; file: { id: string; name: string } }>
|
||||
>(
|
||||
() =>
|
||||
(clearance?.documents ?? [])
|
||||
.filter((d) => d.file)
|
||||
.map((d) => ({ doc: d, file: d.file! })),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
const workflowFiles = useMemo(
|
||||
() => (clearance?.workflowFiles ?? []).filter((f) => f.file),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Duty notice + final invoice + payment slip — loose files that don't ride in
|
||||
// the documents/workflow arrays.
|
||||
const otherFiles = useMemo<LabeledFile[]>(() => {
|
||||
const rows: LabeledFile[] = [];
|
||||
const notice = clearance?.dutyAdvice?.noticeFile;
|
||||
if (notice) rows.push({ label: "Duty & tax notice", file: notice });
|
||||
const inv = clearance?.finalInvoice;
|
||||
if (inv?.invoiceFile)
|
||||
rows.push({ label: `Final invoice · ${inv.invoiceNumber}`, file: inv.invoiceFile });
|
||||
if (inv?.slipFile)
|
||||
rows.push({ label: "Final invoice payment slip", file: inv.slipFile });
|
||||
return rows;
|
||||
}, [clearance]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py={60}>
|
||||
<Group gap={10}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading documents…</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAny =
|
||||
clearanceDocs.length > 0 || workflowFiles.length > 0 || otherFiles.length > 0;
|
||||
|
||||
if (isError || !hasAny) {
|
||||
return (
|
||||
<SectionCard icon={FolderOpen} title="Documents" accent="edr-green">
|
||||
<Center py={28}>
|
||||
<Stack align="center" gap={6}>
|
||||
<FolderOpen size={26} color="var(--mantine-color-gray-5)" />
|
||||
<Text fw={600}>No documents yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={360}>
|
||||
{isError
|
||||
? "Couldn’t load this booking’s documents."
|
||||
: "Documents attached to this booking will appear here as they’re uploaded."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{clearanceDocs.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Clearance documents" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{clearanceDocs.map(({ doc, file }) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={doc.fileKey}
|
||||
label={`${doc.label}${doc.uploadedBy === "gl" ? " · GL" : ""}`}
|
||||
file={file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{workflowFiles.length > 0 && (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{otherFiles.length > 0 && (
|
||||
<SectionCard icon={FileText} title="Invoices & notices" accent="edr-green">
|
||||
<Stack gap={8}>
|
||||
{otherFiles.map((row) => (
|
||||
<PhasedUploadedFileRow
|
||||
key={row.file.id}
|
||||
label={row.label}
|
||||
file={row.file}
|
||||
onView={view}
|
||||
onDownload={onDownload}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<Box>{viewer}</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from "./booking-detail.styles";
|
||||
export * from "./SectionCard";
|
||||
export * from "./ClearanceReviewSection";
|
||||
export * from "./BookingDocumentsPanel";
|
||||
export * from "./ContractOrdersPanel";
|
||||
export * from "./MetricTile";
|
||||
export * from "./BookingDetailToolbar";
|
||||
|
||||
@@ -630,6 +630,20 @@ function DocReviewCard({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile && (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
component="a"
|
||||
href={fileViewUrl(doc.file!.id, true)}
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -646,7 +646,9 @@ function FinalInvoiceStep({
|
||||
const invoice = clearance.finalInvoice ?? null;
|
||||
const paid = invoice?.status === "PAID";
|
||||
|
||||
if (!clearance.offloaded && !invoice) {
|
||||
// Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
|
||||
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
|
||||
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
@@ -740,7 +742,7 @@ function FinalInvoiceStep({
|
||||
) : canDjAct && bookingId ? (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
Cargo offloaded — send the final invoice to the customer.
|
||||
Send the final invoice to the customer if post-arrival charges apply (optional).
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
|
||||
@@ -150,16 +150,19 @@ export default function GlCreateBookingForm() {
|
||||
[bookingWindows],
|
||||
);
|
||||
|
||||
// Soonest future window across all routes, used for the "next window" notice.
|
||||
// Next future window across all routes, used for the "next window" notice —
|
||||
// the train dispatching soonest among those not yet open, matching the
|
||||
// departure-date ordering of the window cards.
|
||||
const nextWindow = useMemo(() => {
|
||||
const now = Date.now();
|
||||
return (bookingWindows ?? [])
|
||||
.filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.windowOpensAt!).getTime() -
|
||||
new Date(b.windowOpensAt!).getTime(),
|
||||
)[0];
|
||||
.sort((a, b) => {
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime();
|
||||
})[0];
|
||||
}, [bookingWindows]);
|
||||
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
|
||||
@@ -21,7 +21,29 @@ import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { api } from "@/services/api";
|
||||
import type { StaffBookingWindow } from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* The fields a window card needs. Structural so both `StaffBookingWindow`
|
||||
* (all-lanes staff feed) and `BookingWindow` (contract-scoped feed, which
|
||||
* carries no train number) satisfy it.
|
||||
*/
|
||||
interface WindowRow {
|
||||
scheduleId: string;
|
||||
reference?: string | null;
|
||||
trainNumber?: string | null;
|
||||
direction: string | null;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
}
|
||||
|
||||
/** All window times are communicated in East Africa Time. */
|
||||
const TZ = "Africa/Addis_Ababa";
|
||||
@@ -46,7 +68,7 @@ function fmtTime(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function windowLabel(w: StaffBookingWindow): string {
|
||||
function windowLabel(w: WindowRow): string {
|
||||
if (w.windowOpensAt && w.windowClosesAt) {
|
||||
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} – ${fmtTime(
|
||||
w.windowClosesAt,
|
||||
@@ -64,7 +86,7 @@ function windowLabel(w: StaffBookingWindow): string {
|
||||
* between refetches announces what comes next rather than the bare "Expired".
|
||||
*/
|
||||
function phaseCountdown(
|
||||
w: StaffBookingWindow,
|
||||
w: WindowRow,
|
||||
): { label: string; deadline: string; expiredText: string } | null {
|
||||
switch (w.windowPhase) {
|
||||
case "PRE_WINDOW":
|
||||
@@ -104,19 +126,18 @@ function phaseCountdown(
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop windows whose booking window (or the train itself) has already passed. */
|
||||
function isPast(w: StaffBookingWindow): boolean {
|
||||
const now = Date.now();
|
||||
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
|
||||
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
|
||||
// Still live while in a post-close staff phase (doc review / payment).
|
||||
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
|
||||
if (departs != null && departs <= now) return true;
|
||||
if (closes != null && closes <= now) return true;
|
||||
return false;
|
||||
/**
|
||||
* Drop windows the SERVER considers finished — keyed off windowPhase, never the
|
||||
* client clock. The server query already excludes terminal / departed rows;
|
||||
* comparing `Date.now()` here only re-introduced clock skew that made a card
|
||||
* vanish and reappear on refresh. Trust the server phase (live-patched over the
|
||||
* socket) instead.
|
||||
*/
|
||||
function isPast(w: WindowRow): boolean {
|
||||
return w.windowPhase === "DONE" || w.windowPhase === "CLOSED_FOR_DAY";
|
||||
}
|
||||
|
||||
function WindowCard({ w }: { w: StaffBookingWindow }) {
|
||||
function WindowCard({ w }: { w: WindowRow }) {
|
||||
const cd = phaseCountdown(w);
|
||||
const open = w.isOpenNow;
|
||||
const isImport = w.direction === "IMPORT";
|
||||
@@ -175,6 +196,11 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
|
||||
{w.destination ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{w.reference ? (
|
||||
<Text fz={12} fw={600} ff="monospace" c="edr-green.7" truncate>
|
||||
{w.reference}
|
||||
</Text>
|
||||
) : null}
|
||||
{w.trainNumber ? (
|
||||
<Text fz={12} c="dimmed" truncate>
|
||||
Train {w.trainNumber}
|
||||
@@ -218,21 +244,45 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface GlUpcomingWindowsSectionProps {
|
||||
/**
|
||||
* Scope the card to one contract: only windows on that contract's routes
|
||||
* (and therefore its import/export direction) are shown. Omit for the
|
||||
* all-lanes staff feed on the clearance queue.
|
||||
*/
|
||||
contractId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All announced booking windows (import cycles + export FCFS) across every lane,
|
||||
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
|
||||
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
|
||||
* Hidden when nothing is pending.
|
||||
* Announced booking windows (import cycles + export FCFS) as a paged carousel —
|
||||
* three lanes per page, arrows to flip. Without `contractId` it shows every
|
||||
* lane (GL ET clearance queue); with `contractId` it shows only the windows
|
||||
* matching that contract's routes/direction (clearance detail page). Mirrors
|
||||
* the customer's portal "Booking Windows" card. Hidden when nothing is pending.
|
||||
*/
|
||||
export function GlUpcomingWindowsSection() {
|
||||
export function GlUpcomingWindowsSection({
|
||||
contractId,
|
||||
}: GlUpcomingWindowsSectionProps = {}) {
|
||||
// Live pushes flip cards the moment the window engine transitions a phase;
|
||||
// the 60s poll below stays only as a fallback.
|
||||
useBookingWindowSocket();
|
||||
const { data, isLoading } = useQuery(
|
||||
api.trainScheduling.allBookingWindows.queryOptions({
|
||||
const allLanes = useQuery({
|
||||
...api.trainScheduling.allBookingWindows.queryOptions({
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
enabled: !contractId,
|
||||
});
|
||||
const contractLanes = useQuery({
|
||||
...api.trainScheduling.contractBookingWindows.queryOptions({
|
||||
input: { contractId: contractId ?? "" },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
enabled: Boolean(contractId),
|
||||
});
|
||||
const data: WindowRow[] | undefined = contractId
|
||||
? contractLanes.data
|
||||
: allLanes.data;
|
||||
const isLoading = contractId ? contractLanes.isLoading : allLanes.isLoading;
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const windows = useMemo(() => {
|
||||
@@ -241,13 +291,13 @@ export function GlUpcomingWindowsSection() {
|
||||
);
|
||||
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
|
||||
// guard above already excludes them; they never reach the upcoming list.
|
||||
// Open lanes first, then by opening time.
|
||||
// Order by the train's dispatch (departure) date, nearest first. Open-now
|
||||
// breaks ties on the same departure.
|
||||
return rows.sort((a, b) => {
|
||||
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
if (openDiff !== 0) return openDiff;
|
||||
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
|
||||
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
|
||||
return at - bt;
|
||||
const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
|
||||
const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
|
||||
if (da !== db) return da - db;
|
||||
return Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
@@ -270,7 +320,9 @@ export function GlUpcomingWindowsSection() {
|
||||
Booking windows
|
||||
</Text>
|
||||
<Text fz={13} c="dimmed">
|
||||
Import and export booking windows across all lanes (EAT)
|
||||
{contractId
|
||||
? "Booking windows on this contract's routes (EAT)"
|
||||
: "Import and export booking windows across all lanes (EAT)"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
@@ -727,9 +727,9 @@ function ImportT1UploadStep({
|
||||
);
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
|
||||
// closes/accepts the T1.
|
||||
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
</Alert>
|
||||
) : uploaded.length === 0 && !canUpload ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FilePlus2,
|
||||
FileX2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useFileViewer } from "@edr/ui-common";
|
||||
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyChangeRequest } from "@/types/customer";
|
||||
import { formatDate, humanize } from "./format";
|
||||
|
||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
companyName: "Company name",
|
||||
companyEmail: "Company email",
|
||||
companyPhone: "Company phone",
|
||||
companyLocation: "Location",
|
||||
companyAddress: "Address",
|
||||
tin: "TIN",
|
||||
vatNumber: "VAT number",
|
||||
fanNumber: "FAN number",
|
||||
nationality: "Nationality",
|
||||
licenceNumber: "Licence number",
|
||||
contactPersonName: "Contact person",
|
||||
contactPersonPosition: "Contact position",
|
||||
contactPersonEmail: "Contact email",
|
||||
contactPersonPhone: "Contact phone",
|
||||
generalManagerName: "General manager",
|
||||
generalManagerEmail: "GM email",
|
||||
generalManagerPhone: "GM phone",
|
||||
poaName: "PoA name",
|
||||
poaPhone: "PoA phone",
|
||||
poaEmail: "PoA email",
|
||||
poaLocation: "PoA location",
|
||||
poaAddress: "PoA address",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
function currentValue(company: Company, key: string): string {
|
||||
const c = company as unknown as Record<string, unknown>;
|
||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||
const map: Record<string, unknown> = {
|
||||
companyName: c.name,
|
||||
companyEmail: c.email,
|
||||
companyPhone: c.phone,
|
||||
companyLocation: c.country,
|
||||
companyAddress: c.address,
|
||||
tin: c.tin,
|
||||
vatNumber: c.vatNumber,
|
||||
fanNumber: c.fanNumber,
|
||||
nationality: c.nationality,
|
||||
contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
|
||||
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
|
||||
generalManagerName: c.generalManagerName ?? attrs.generalManagerName,
|
||||
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail,
|
||||
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone,
|
||||
};
|
||||
const v = key in map ? map[key] : (c[key] ?? attrs[key]);
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}) {
|
||||
const changed = from !== to;
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={600} c="edr-muted" tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap={8} wrap="nowrap" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
td={changed ? "line-through" : undefined}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{from}
|
||||
</Text>
|
||||
{changed && (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
→
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{to}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice review surface for a customer's staged profile edits. Shows the
|
||||
* pending change request as a proposed-vs-current diff with Approve / Reject
|
||||
* (with note) actions, plus a short history of past decisions.
|
||||
*/
|
||||
export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: company.id } }),
|
||||
);
|
||||
const approve = useMutation(
|
||||
api.customers.approveChangeRequest.mutationOptions(),
|
||||
);
|
||||
const reject = useMutation(
|
||||
api.customers.rejectChangeRequest.mutationOptions(),
|
||||
);
|
||||
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const requests = query.data ?? [];
|
||||
const pending = requests.find((r) => r.status === "pending");
|
||||
const history = requests.filter((r) => r.status !== "pending").slice(0, 5);
|
||||
|
||||
if (!pending && history.length === 0) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
: ([] as string[]);
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejectId) return;
|
||||
reject.mutate(
|
||||
{ id: rejectId, note: note.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRejectId(null);
|
||||
setNote("");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{pending && (
|
||||
<Card withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ClipboardCheck size={18} className="text-edr-muted" />
|
||||
<Text fw={600} c="edr-text">
|
||||
Profile changes awaiting review
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="md">
|
||||
Pending
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Submitted {formatDate(pending.submittedAt ?? pending.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{proposedKeys.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{proposedKeys.map((key) => (
|
||||
<DiffRow
|
||||
key={key}
|
||||
label={FIELD_LABELS[key] ?? humanize(key)}
|
||||
from={currentValue(company, key)}
|
||||
to={
|
||||
pending.snapshot[key] === null ||
|
||||
pending.snapshot[key] === undefined ||
|
||||
pending.snapshot[key] === ""
|
||||
? "—"
|
||||
: String(pending.snapshot[key])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{docCount > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{docCount} document{docCount === 1 ? "" : "s"} uploaded with this
|
||||
request — review them in the Documents tab.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{licenseChanges.length > 0 && (
|
||||
<Stack gap={8}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
Business license changes
|
||||
</Text>
|
||||
{licenseChanges.map((c, i) => (
|
||||
<Group key={`${c.fileId}-${i}`} gap={8} wrap="nowrap">
|
||||
{c.op === "add" ? (
|
||||
<FilePlus2 size={15} className="text-edr-muted" />
|
||||
) : (
|
||||
<FileX2 size={15} className="text-edr-muted" />
|
||||
)}
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={c.op === "add" ? "green" : "red"}
|
||||
>
|
||||
{c.op === "add" ? "Add" : "Remove"}
|
||||
</Badge>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: c.fileName ?? "License document",
|
||||
url: fileViewUrl(c.fileId),
|
||||
})
|
||||
}
|
||||
style={{
|
||||
textDecoration:
|
||||
c.op === "remove" ? "line-through" : undefined,
|
||||
}}
|
||||
>
|
||||
{c.fileName ?? "License document"}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setRejectId(pending.id);
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate({ id: pending.id })}
|
||||
>
|
||||
Approve changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
Review history
|
||||
</Text>
|
||||
{history.map((r: CompanyChangeRequest) => (
|
||||
<Group key={r.id} gap="sm" wrap="nowrap" align="flex-start">
|
||||
<Badge
|
||||
color={r.status === "approved" ? "edr-green" : "red"}
|
||||
variant="light"
|
||||
radius="md"
|
||||
tt="capitalize"
|
||||
>
|
||||
{r.status}
|
||||
</Badge>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="sm" c="edr-text">
|
||||
{formatDate(r.reviewedAt ?? r.updatedAt)}
|
||||
</Text>
|
||||
{r.note && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Note: {r.note}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={rejectId !== null}
|
||||
onClose={() => setRejectId(null)}
|
||||
title="Reject changes"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertTriangle size={18} />}>
|
||||
The customer will see this note and can amend and resubmit.
|
||||
</Alert>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The company address doesn't match the trade license."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectId(null)}
|
||||
disabled={reject.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={reject.isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "N changes pending" pill for the customer list/detail header. */
|
||||
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
|
||||
const query = useQuery(
|
||||
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
||||
);
|
||||
const pending = (query.data ?? []).some((r) => r.status === "pending");
|
||||
if (!pending) return null;
|
||||
return (
|
||||
<Badge
|
||||
color="yellow"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Clock size={12} />}
|
||||
>
|
||||
Changes pending review
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Badge, Button, Group, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
import type {
|
||||
@@ -25,6 +35,7 @@ const badgeStyle = {
|
||||
const STATUS_COLOR: Record<CompanyStatus | ProfileStatus, string> = {
|
||||
active: "edr-green",
|
||||
pending: "yellow",
|
||||
rejected: "red",
|
||||
suspended: "orange",
|
||||
blacklisted: "red",
|
||||
};
|
||||
@@ -177,6 +188,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
|
||||
APPROVED: "cyan",
|
||||
PAID: "edr-green",
|
||||
IN_TRANSIT: "blue",
|
||||
ARRIVED: "teal",
|
||||
COMPLETED: "indigo",
|
||||
REJECTED: "red",
|
||||
CANCELLED: "red",
|
||||
@@ -265,7 +277,9 @@ export function InvoiceStatusBadge({
|
||||
|
||||
/**
|
||||
* Inline approval action buttons for a profile row.
|
||||
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
|
||||
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
||||
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||
* Rejecting captures a note the customer sees so they can fix and reapply.
|
||||
*/
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
@@ -277,33 +291,102 @@ export function ProfileApprovalActions({
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
|
||||
|
||||
const confirmReject = () => {
|
||||
mutate(
|
||||
{ profileId, status: "rejected", note: note.trim() },
|
||||
{ onSuccess: () => setRejectOpen(false) },
|
||||
);
|
||||
};
|
||||
|
||||
const rejectModal = (
|
||||
<Modal
|
||||
opened={rejectOpen}
|
||||
onClose={() => setRejectOpen(false)}
|
||||
title="Reject profile"
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tell the customer what needs fixing. They'll see this note and can
|
||||
amend and resubmit the role for approval.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason for rejection"
|
||||
placeholder="e.g. The uploaded business license is expired."
|
||||
autosize
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setRejectOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={isPending}
|
||||
disabled={note.trim().length === 0}
|
||||
onClick={confirmReject}
|
||||
>
|
||||
Reject profile
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("blacklisted")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{rejectModal}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
loading={isPending}
|
||||
onClick={() => act("active")}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,5 +9,9 @@ export {
|
||||
ProfileStatusBadge,
|
||||
ProfileTypeBadge,
|
||||
} from "./badges";
|
||||
export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||
export { TableCard, type TableCardProps } from "./TableCard";
|
||||
|
||||
@@ -33,7 +33,9 @@ const FleetRecordActions = ({
|
||||
const isVehicle = config.slug === "vehicles";
|
||||
const showHistory =
|
||||
Boolean(onHistory) &&
|
||||
(config.slug === "drivers" || config.slug === "vehicles");
|
||||
(config.slug === "drivers" ||
|
||||
config.slug === "vehicles" ||
|
||||
config.slug === "wagons");
|
||||
|
||||
const handleDetail = () => {
|
||||
if (!config.detailPath || !("id" in record)) return;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
import type { WagonMovementRecord } from "@/services/wagon.service";
|
||||
|
||||
export interface WagonMovementHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
record: FleetRecord | null;
|
||||
}
|
||||
|
||||
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
|
||||
|
||||
/** Chip style per wagon_movements ledger kind. */
|
||||
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
|
||||
LOADED: {
|
||||
label: "Loaded leg",
|
||||
color: "edr-green",
|
||||
icon: <PackageCheck size={14} />,
|
||||
},
|
||||
EMPTY_REPOSITION: {
|
||||
label: "Empty reposition",
|
||||
color: "blue",
|
||||
icon: <TrainFront size={14} />,
|
||||
},
|
||||
MANUAL: {
|
||||
label: "Manual move",
|
||||
color: "orange",
|
||||
icon: <Wrench size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
const yardLabel = (
|
||||
yard: { label?: string; code?: string } | null | undefined,
|
||||
yardId: string | null,
|
||||
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
|
||||
|
||||
const fmt = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Movement ledger for one wagon: every relocation between yards — booking legs,
|
||||
* empty reposition rides, and manual staff corrections — newest first.
|
||||
*/
|
||||
const WagonMovementHistoryModal = ({
|
||||
opened,
|
||||
onClose,
|
||||
record,
|
||||
}: WagonMovementHistoryModalProps) => {
|
||||
const r = asObj(record);
|
||||
const id = r.id ? String(r.id) : "";
|
||||
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
|
||||
|
||||
const { data, isLoading } = useQuery(
|
||||
api.wagons.movements.queryOptions({
|
||||
input: { id },
|
||||
enabled: opened && Boolean(id),
|
||||
}),
|
||||
);
|
||||
|
||||
const movements: WagonMovementRecord[] = data ?? [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
|
||||
radius="lg"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : movements.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No movements recorded yet. Every yard-to-yard move appears here — a
|
||||
booking's loaded leg, an empty reposition ride, or a manual correction.
|
||||
</Text>
|
||||
) : (
|
||||
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
|
||||
{movements.map((movement) => {
|
||||
const meta = KIND_META[movement.kind] ?? {
|
||||
label: movement.kind,
|
||||
color: "gray",
|
||||
icon: <TrainFront size={14} />,
|
||||
};
|
||||
const from = yardLabel(movement.fromYard, movement.fromYardId);
|
||||
const to = yardLabel(movement.toYard, movement.toYardId);
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={movement.id}
|
||||
bullet={meta.icon}
|
||||
title={
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{from}
|
||||
</Text>
|
||||
<ArrowRight size={13} />
|
||||
<Text size="sm" fw={600}>
|
||||
{to}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={meta.color}>
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{movement.note && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{movement.note}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" mt={4} c="dimmed">
|
||||
{fmt(movement.occurredAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WagonMovementHistoryModal;
|
||||
@@ -154,7 +154,37 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned.
|
||||
</Text>
|
||||
)}
|
||||
{preview.tiers && preview.tiers.length > 0 ? (
|
||||
{preview.groups && preview.groups.length > 1 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>Trucks</Table.Th>
|
||||
<Table.Th>Days</Table.Th>
|
||||
<Table.Th ta="right">Rate / truck / day</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{preview.groups.map((g, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
{g.vehicleType ?? 'Unknown'}
|
||||
{!g.ruleId && (
|
||||
<Text span size="xs" c="red">
|
||||
{' '}· no rule
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{g.truckCount}</Table.Td>
|
||||
<Table.Td>{g.chargeableDays}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.ratePerDay, preview.currency)}</Table.Td>
|
||||
<Table.Td ta="right">{money(g.amount, preview.currency)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : preview.tiers && preview.tiers.length > 0 ? (
|
||||
<Table withTableBorder withColumnBorders verticalSpacing="xs" fz="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Crown,
|
||||
Container,
|
||||
Boxes,
|
||||
FlaskConical,
|
||||
Layers,
|
||||
Ruler,
|
||||
Scale,
|
||||
Sparkles,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { BatchBoardScheduleDetail } from "@/types/trainScheduling";
|
||||
import {
|
||||
simulateBatch,
|
||||
limitsFromDetail,
|
||||
type BlockingAxis,
|
||||
type ForecastRow,
|
||||
} from "./batchForecast";
|
||||
|
||||
type Props = {
|
||||
data: BatchBoardScheduleDetail;
|
||||
bookings: BatchBoardScheduleDetail["pendingContract"]["bookings"];
|
||||
};
|
||||
|
||||
const cardVar = (color: string, shade: number) =>
|
||||
`var(--mantine-color-${color}-${shade})`;
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
const fmtMeters = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} m`;
|
||||
|
||||
const AXIS_LABEL: Record<BlockingAxis, string> = {
|
||||
wagons: "wagon slots full",
|
||||
weight: "over max pull weight",
|
||||
length: "over train length",
|
||||
};
|
||||
|
||||
/** One capacity axis as a labelled meter (used vs cap). */
|
||||
function AxisMeter({
|
||||
icon: Icon,
|
||||
label,
|
||||
used,
|
||||
cap,
|
||||
fmt,
|
||||
color,
|
||||
}: {
|
||||
icon: typeof Scale;
|
||||
label: string;
|
||||
used: number;
|
||||
cap: number | null;
|
||||
fmt: (n: number) => string;
|
||||
color: string;
|
||||
}) {
|
||||
const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0;
|
||||
const near = pct >= 90;
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 150 }}>
|
||||
<Group justify="space-between" mb={4} wrap="nowrap">
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Icon size={13} color={cardVar(color, 6)} />
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" fw={700} c={near ? `${color}.8` : "dark.4"}>
|
||||
{fmt(used)}
|
||||
{cap != null ? ` / ${fmt(cap)}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={pct}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={near ? color : "edr-green"}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FreightIcon({ type }: { type: string | null }) {
|
||||
const Icon = type === "BULK" ? Boxes : Container;
|
||||
return (
|
||||
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single forecast row: rank, booking, capacity contribution, projected verdict. */
|
||||
function ForecastCard({ row }: { row: ForecastRow }) {
|
||||
const { booking, rank, selected, blockedBy } = row;
|
||||
const gov = booking.isGovernment;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: selected
|
||||
? cardVar("edr-green", 3)
|
||||
: cardVar("gray", 2),
|
||||
background: selected
|
||||
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 55%)`
|
||||
: "var(--mantine-color-white)",
|
||||
opacity: selected ? 1 : 0.92,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
size={32}
|
||||
radius="xl"
|
||||
variant={selected && rank <= 3 ? "filled" : "light"}
|
||||
color={gov ? "grape" : selected ? "edr-green" : "gray"}
|
||||
style={{ flexShrink: 0, fontWeight: 800 }}
|
||||
>
|
||||
{gov ? (
|
||||
<Crown size={15} />
|
||||
) : (
|
||||
<Text fw={800} size="sm">
|
||||
{rank}
|
||||
</Text>
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<FreightIcon type={booking.freightType} />
|
||||
{gov ? (
|
||||
<Tooltip label="Government — boards first" withArrow>
|
||||
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
|
||||
<Crown size={10} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
|
||||
{/* score */}
|
||||
<Group gap={4} wrap="nowrap" w={70} justify="flex-end">
|
||||
<Trophy size={12} color={cardVar("edr-green", 6)} />
|
||||
<Text fw={800} size="sm" c="edr-green.7">
|
||||
{booking.priorityScore}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* wagons + weight this booking adds */}
|
||||
<Group gap={4} wrap="nowrap" w={64} justify="flex-end">
|
||||
<TrainFront size={13} color={cardVar("gray", 6)} />
|
||||
<Text fw={700} size="sm">
|
||||
{booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" w={64} ta="right">
|
||||
{fmtTons(booking.weightTons)}
|
||||
</Text>
|
||||
{/* verdict */}
|
||||
<Box w={150} style={{ textAlign: "right" }}>
|
||||
{selected ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<Sparkles size={11} />}
|
||||
>
|
||||
Would board
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
blockedBy
|
||||
? `Doesn't fit — ${AXIS_LABEL[blockedBy]}`
|
||||
: "Below the capacity line"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Waiting list
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Cut line between the simulated batch and the simulated waiting list. */
|
||||
function CutLine({ full }: { full: boolean }) {
|
||||
return (
|
||||
<Group gap="xs" my={2} wrap="nowrap">
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
|
||||
<Layers size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="orange.7">
|
||||
Forecast capacity line{full ? " · TRAIN FULL" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forecast / "what-if" panel. Simulates the batch engine's greedy fill on the
|
||||
* current pool and shows the projected winners + waiting list BEFORE document
|
||||
* review closes. Not the real selection — the engine commits that when staff run
|
||||
* the batch after the review window ends.
|
||||
*/
|
||||
export function ForecastPanel({ data, bookings }: Props) {
|
||||
const limits = useMemo(() => limitsFromDetail(data), [data]);
|
||||
const sim = useMemo(
|
||||
() => simulateBatch(bookings, limits),
|
||||
[bookings, limits],
|
||||
);
|
||||
|
||||
const noCaps =
|
||||
limits.maxWagons == null &&
|
||||
limits.maxWeightTons == null &&
|
||||
limits.maxLengthMeters == null;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Header + explainer */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Group justify="space-between" wrap="wrap" gap="md" mb="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="violet" radius="md" size="lg">
|
||||
<FlaskConical size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Group gap={8}>
|
||||
<Text fw={700}>Forecast batch (simulated)</Text>
|
||||
<Badge variant="light" color="violet" radius="sm" size="sm">
|
||||
Preview
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" maw={520}>
|
||||
What the batch engine would pick if it ran now — greedy fill by
|
||||
priority until the train is full. The real selection happens when
|
||||
document review ends and staff run the batch.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="lg">
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xl" fw={800} c="edr-green.7">
|
||||
{sim.selected.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
would board
|
||||
</Text>
|
||||
</Stack>
|
||||
<Stack gap={0} align="flex-end">
|
||||
<Text size="xl" fw={800} c="gray.7">
|
||||
{sim.waiting.length}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
waiting list
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Three capacity axes */}
|
||||
<Group gap="lg" align="flex-end" wrap="wrap">
|
||||
<AxisMeter
|
||||
icon={TrainFront}
|
||||
label="Wagon slots"
|
||||
used={sim.usedWagons}
|
||||
cap={limits.maxWagons}
|
||||
fmt={(n) => `${n}`}
|
||||
color="edr-green"
|
||||
/>
|
||||
<AxisMeter
|
||||
icon={Scale}
|
||||
label="Max pull weight"
|
||||
used={sim.usedWeightTons}
|
||||
cap={limits.maxWeightTons}
|
||||
fmt={fmtTons}
|
||||
color="orange"
|
||||
/>
|
||||
<AxisMeter
|
||||
icon={Ruler}
|
||||
label="Train length"
|
||||
used={sim.usedLengthMeters}
|
||||
cap={limits.maxLengthMeters}
|
||||
fmt={fmtMeters}
|
||||
color="blue"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{noCaps ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
mt="md"
|
||||
radius="md"
|
||||
icon={<XCircle size={16} />}
|
||||
>
|
||||
No locomotive / capacity limits on this schedule yet — forecast can't
|
||||
draw the capacity line. Assign a locomotive to simulate the fill.
|
||||
</Alert>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
{sim.rows.length === 0 ? (
|
||||
<Paper radius="lg" withBorder p="xl">
|
||||
<Text c="dimmed" ta="center">
|
||||
No eligible bookings to forecast yet.
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
{/* WOULD BOARD */}
|
||||
{sim.selected.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Projected batch{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({sim.selected.length}) — top priority, fits capacity
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{sim.selected.map((r) => (
|
||||
<ForecastCard key={r.booking.id} row={r} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<CutLine full={sim.full} />
|
||||
|
||||
{/* WAITING LIST */}
|
||||
{sim.waiting.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Layers size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Projected waiting list{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({sim.waiting.length}) — boards only if a slot frees up
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{sim.waiting.map((r) => (
|
||||
<ForecastCard key={r.booking.id} row={r} />
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* INELIGIBLE (expired / pending contract) */}
|
||||
{sim.ineligible.length > 0 ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{sim.ineligible.length} booking
|
||||
{sim.ineligible.length === 1 ? "" : "s"} not in the forecast
|
||||
(expired or contract not signed).
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ForecastPanel;
|
||||
@@ -0,0 +1,614 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Container,
|
||||
Crown,
|
||||
FlaskConical,
|
||||
Hourglass,
|
||||
Layers,
|
||||
ListOrdered,
|
||||
Radio,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { CountdownTimer } from "@edr/ui-common";
|
||||
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardBookingState,
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
import { WindowPhasePill } from "./batchVisuals";
|
||||
import { ForecastPanel } from "./ForecastPanel";
|
||||
import { forecastIsLive } from "./batchForecast";
|
||||
|
||||
/**
|
||||
* Priority Tracking tab — live, glanceable ranking of every booking on this
|
||||
* schedule in the exact order the batch engine boards them (government first,
|
||||
* then rule-engine priority score, then oldest). Bookings above the train's
|
||||
* wagon-capacity line render as "selected" (green), below it as the waiting
|
||||
* list; during the PAYMENT phase selected bookings show a live pay-window
|
||||
* countdown. Purely presentational — data comes from the batch-board detail
|
||||
* response the page already polls (+ socket-invalidates).
|
||||
*/
|
||||
|
||||
type Props = {
|
||||
data: BatchBoardScheduleDetail;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
};
|
||||
|
||||
const STATE_STYLE: Record<
|
||||
BatchBoardBookingState,
|
||||
{ label: string; color: string; icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
ALLOCATED: { label: "Allocated", color: "edr-green", icon: CheckCircle2 },
|
||||
SELECTED_FOR_BATCH: { label: "Selected · pay now", color: "orange", icon: Clock },
|
||||
READY: { label: "Ready", color: "teal", icon: Hourglass },
|
||||
WAITING: { label: "Paid · waiting slot", color: "blue", icon: Hourglass },
|
||||
PENDING_CONTRACT: { label: "Pending contract", color: "gray", icon: Hourglass },
|
||||
EXPIRED: { label: "Expired", color: "red", icon: XCircle },
|
||||
};
|
||||
|
||||
/** States that occupy a wagon slot on this train (i.e. are "in" the batch). */
|
||||
const OCCUPIES_SLOT: BatchBoardBookingState[] = [
|
||||
"ALLOCATED",
|
||||
"SELECTED_FOR_BATCH",
|
||||
"WAITING",
|
||||
];
|
||||
|
||||
const cardVar = (color: string, shade: number) =>
|
||||
`var(--mantine-color-${color}-${shade})`;
|
||||
|
||||
/** Highest score across the ranked pool → used to scale the priority mini-bar. */
|
||||
function maxScore(bookings: BatchBoardBookingDetail[]): number {
|
||||
return bookings.reduce((m, b) => Math.max(m, b.priorityScore ?? 0), 0);
|
||||
}
|
||||
|
||||
function FreightIcon({ type }: { type: string | null }) {
|
||||
const Icon = type === "BULK" ? Boxes : Container;
|
||||
return (
|
||||
<Tooltip label={type === "BULK" ? "Bulk" : "Container"} withArrow>
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One ranked booking row rendered as a card, colored by its batch state. */
|
||||
function RankedCard({
|
||||
rank,
|
||||
booking,
|
||||
scoreMax,
|
||||
phase,
|
||||
isPayPhase,
|
||||
}: {
|
||||
rank: number;
|
||||
booking: BatchBoardBookingDetail;
|
||||
scoreMax: number;
|
||||
phase: string | null;
|
||||
isPayPhase: boolean;
|
||||
}) {
|
||||
const style = STATE_STYLE[booking.state];
|
||||
const Icon = style.icon;
|
||||
const selected = booking.state === "SELECTED_FOR_BATCH";
|
||||
const allocated = booking.state === "ALLOCATED";
|
||||
const expired = booking.state === "EXPIRED";
|
||||
// Green surface for the winners (allocated + selected); muted for the rest.
|
||||
const surfaceColor = allocated
|
||||
? "edr-green"
|
||||
: selected
|
||||
? "edr-green"
|
||||
: expired
|
||||
? "red"
|
||||
: "gray";
|
||||
const scorePct =
|
||||
scoreMax > 0 ? Math.max(4, Math.round((booking.priorityScore / scoreMax) * 100)) : 0;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
borderColor: cardVar(surfaceColor, allocated || selected ? 4 : 2),
|
||||
background:
|
||||
allocated || selected
|
||||
? `linear-gradient(90deg, ${cardVar("edr-green", 0)} 0%, var(--mantine-color-white) 60%)`
|
||||
: expired
|
||||
? cardVar("red", 0)
|
||||
: "var(--mantine-color-white)",
|
||||
opacity: expired ? 0.72 : 1,
|
||||
transition: "background 200ms ease, border-color 200ms ease",
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="sm">
|
||||
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0 }}>
|
||||
{/* Rank medallion */}
|
||||
<ThemeIcon
|
||||
size={34}
|
||||
radius="xl"
|
||||
variant={rank <= 3 ? "filled" : "light"}
|
||||
color={
|
||||
booking.isGovernment
|
||||
? "grape"
|
||||
: rank <= 3
|
||||
? "edr-green"
|
||||
: "gray"
|
||||
}
|
||||
style={{ flexShrink: 0, fontWeight: 800 }}
|
||||
>
|
||||
{booking.isGovernment ? (
|
||||
<Crown size={16} />
|
||||
) : (
|
||||
<Text fw={800} size="sm">
|
||||
{rank}
|
||||
</Text>
|
||||
)}
|
||||
</ThemeIcon>
|
||||
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<FreightIcon type={booking.freightType} />
|
||||
{booking.isGovernment ? (
|
||||
<Tooltip label="Government — boards first" withArrow>
|
||||
<ThemeIcon size="xs" radius="sm" variant="light" color="grape">
|
||||
<Crown size={11} />
|
||||
</ThemeIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{booking.company}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Group wrap="nowrap" gap="lg" style={{ flexShrink: 0 }}>
|
||||
{/* Priority score with a mini strength bar */}
|
||||
<Tooltip
|
||||
label={`Priority score ${booking.priorityScore}${booking.isGovernment ? " + government bonus" : ""}`}
|
||||
withArrow
|
||||
>
|
||||
<Stack gap={2} align="flex-end" w={92}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Trophy size={12} color={cardVar("edr-green", 6)} />
|
||||
<Text fw={800} size="sm" c="edr-green.7">
|
||||
{booking.priorityScore}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={scorePct}
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
w={92}
|
||||
radius="xl"
|
||||
/>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
|
||||
{/* Wagons */}
|
||||
<Group gap={4} wrap="nowrap" w={58} justify="flex-end">
|
||||
<TrainFront size={13} color={cardVar("gray", 6)} />
|
||||
<Text fw={700} size="sm">
|
||||
{booking.wagons}w
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* State chip / pay countdown */}
|
||||
<Box w={168} style={{ textAlign: "right" }}>
|
||||
{selected && isPayPhase && booking.paymentDeadline ? (
|
||||
<CountdownTimer
|
||||
deadline={booking.paymentDeadline}
|
||||
label="Pay in"
|
||||
expiredText="Window closed"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<Group gap={5} justify="flex-end" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={style.color}
|
||||
>
|
||||
<Icon size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={600} c={`${style.color}.7`}>
|
||||
{style.label}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</Group>
|
||||
{/* phase hint only used for the a11y title; keeps `phase` referenced */}
|
||||
<span hidden aria-hidden>
|
||||
{phase}
|
||||
</span>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** The capacity cut line drawn between "in the batch" and "waiting list". */
|
||||
function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
||||
const full = max != null && used >= max;
|
||||
return (
|
||||
<Group gap="xs" my={4} wrap="nowrap">
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color="orange">
|
||||
<Layers size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" fw={700} c="orange.7">
|
||||
Capacity line{max != null ? ` · ${used}/${max} wagons` : ` · ${used} wagons`}
|
||||
{full ? " · FULL" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1, height: 2, background: cardVar("orange", 3) }} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
const phase = data.windowPhase;
|
||||
const isPayPhase = phase === "PAYMENT";
|
||||
|
||||
// Before the batch is committed (pre-window / open / doc-review) the real
|
||||
// selection doesn't exist yet — offer a simulated forecast of who WOULD board.
|
||||
// Default to it while it's live; let staff flip to the current live state.
|
||||
const forecastAvailable = forecastIsLive(phase);
|
||||
const [view, setView] = useState<"forecast" | "live">(
|
||||
forecastAvailable ? "forecast" : "live",
|
||||
);
|
||||
const showForecast = forecastAvailable && view === "forecast";
|
||||
|
||||
// Rank exactly as the batch engine does: government first, then priority score
|
||||
// desc, then oldest (fullyExecutedAt / selectedForBatchAt as the tiebreak the
|
||||
// backend uses). The board already returns them in this order, but re-sort
|
||||
// defensively so the tab is correct even if the source order ever changes.
|
||||
const ranked = useMemo(() => {
|
||||
const time = (b: BatchBoardBookingDetail) =>
|
||||
b.fullyExecutedAt ? new Date(b.fullyExecutedAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (b.priorityScore !== a.priorityScore) return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
});
|
||||
}, [bookings]);
|
||||
|
||||
const scoreMax = useMemo(() => maxScore(ranked), [ranked]);
|
||||
// maxWagons is not on the board DTO (capacity is length/weight-based), so the
|
||||
// capacity line shows the wagons currently committed rather than a hard cap.
|
||||
const maxWagons: number | null = null;
|
||||
|
||||
// Split the ranking at the capacity line: cumulative wagons of slot-occupying
|
||||
// bookings (allocated + selected + paid-waiting) up to the train's wagon cap.
|
||||
const capUsed = useMemo(
|
||||
() =>
|
||||
ranked
|
||||
.filter((b) => OCCUPIES_SLOT.includes(b.state))
|
||||
.reduce((sum, b) => sum + b.wagons, 0),
|
||||
[ranked],
|
||||
);
|
||||
|
||||
// Group for the lane layout.
|
||||
const lanes = useMemo(() => {
|
||||
const inBatch = ranked.filter((b) => OCCUPIES_SLOT.includes(b.state));
|
||||
const waiting = ranked.filter(
|
||||
(b) => b.state === "READY" || b.state === "PENDING_CONTRACT",
|
||||
);
|
||||
const expired = ranked.filter((b) => b.state === "EXPIRED");
|
||||
return { inBatch, waiting, expired };
|
||||
}, [ranked]);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
return (
|
||||
<Paper radius="lg" withBorder p="xl">
|
||||
<Group justify="center" gap="sm">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size="lg">
|
||||
<ListOrdered size={18} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">No bookings on this schedule yet.</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
let rankNo = 0;
|
||||
|
||||
const viewToggle = forecastAvailable ? (
|
||||
<SegmentedControl
|
||||
value={view}
|
||||
onChange={(v) => setView(v as "forecast" | "live")}
|
||||
size="sm"
|
||||
radius="md"
|
||||
data={[
|
||||
{
|
||||
value: "forecast",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FlaskConical size={13} />
|
||||
<Text size="xs" fw={600}>
|
||||
Forecast
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "live",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Radio size={13} />
|
||||
<Text size="xs" fw={600}>
|
||||
Live state
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (showForecast) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
|
||||
<ForecastPanel data={data} bookings={ranked} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{viewToggle ? <Group justify="flex-end">{viewToggle}</Group> : null}
|
||||
{/* Header: phase + capacity meter */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Group justify="space-between" wrap="wrap" gap="md">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size="lg">
|
||||
<Trophy size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2}>
|
||||
<Text fw={700}>Priority ranking</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Government first, then rule-engine score, then earliest booked.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{phase ? (
|
||||
<WindowPhasePill phase={phase} cycleNo={data.bookingCycleNo} />
|
||||
) : null}
|
||||
{isPayPhase && data.paymentPhaseEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.paymentPhaseEndsAt}
|
||||
label="Payment window"
|
||||
expiredText="Window closed"
|
||||
size="md"
|
||||
/>
|
||||
) : phase === "DOC_REVIEW" && data.docReviewEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.docReviewEndsAt}
|
||||
label="Doc review ends"
|
||||
expiredText="Review over"
|
||||
size="md"
|
||||
/>
|
||||
) : phase === "OPEN" && data.windowClosesAt ? (
|
||||
<CountdownTimer
|
||||
deadline={data.windowClosesAt}
|
||||
label="Booking closes"
|
||||
expiredText="Closed"
|
||||
size="md"
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Capacity meter */}
|
||||
<Box mt="md">
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
Wagon capacity used
|
||||
</Text>
|
||||
<Text size="xs" fw={700}>
|
||||
{data.capacity.allocatedWagons} allocated ·{" "}
|
||||
{capUsed} in batch
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress.Root size="lg" radius="xl">
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
|
||||
: 0
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
? Math.min(
|
||||
100,
|
||||
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
|
||||
)
|
||||
: 0
|
||||
}
|
||||
color="orange"
|
||||
/>
|
||||
</Progress.Root>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* Phase banner explaining what's happening now */}
|
||||
<PhaseBanner phase={phase} />
|
||||
|
||||
{/* IN THE BATCH (green winners) — ranked */}
|
||||
{lanes.inBatch.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="edr-green">
|
||||
<CheckCircle2 size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
In the batch{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.inBatch.length})
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.inBatch.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<CapacityDivider used={capUsed} max={maxWagons} />
|
||||
|
||||
{/* WAITING LIST — ranked, below the line */}
|
||||
{lanes.waiting.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="blue">
|
||||
<Hourglass size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm">
|
||||
Waiting list{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.waiting.length}) — next in line if a slot frees up
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.waiting.map((b) => {
|
||||
rankNo += 1;
|
||||
return (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={rankNo}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* EXPIRED */}
|
||||
{lanes.expired.length > 0 ? (
|
||||
<Stack gap={6}>
|
||||
<Group gap="xs">
|
||||
<ThemeIcon size="sm" radius="sm" variant="light" color="red">
|
||||
<XCircle size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700} size="sm" c="red.7">
|
||||
Expired{" "}
|
||||
<Text span c="dimmed" fw={500}>
|
||||
({lanes.expired.length}) — missed the payment window
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
{lanes.expired.map((b) => (
|
||||
<RankedCard
|
||||
key={b.id}
|
||||
rank={0}
|
||||
booking={b}
|
||||
scoreMax={scoreMax}
|
||||
phase={phase}
|
||||
isPayPhase={isPayPhase}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Contextual banner describing the current window phase in plain language. */
|
||||
function PhaseBanner({ phase }: { phase: string | null }) {
|
||||
const meta: Record<string, { color: string; text: string; icon: typeof Clock }> = {
|
||||
OPEN: {
|
||||
color: "edr-green",
|
||||
icon: Clock,
|
||||
text: "Booking window OPEN — new bookings are ranked live as they arrive and get accepted.",
|
||||
},
|
||||
DOC_REVIEW: {
|
||||
color: "yellow",
|
||||
icon: Hourglass,
|
||||
text: "Document review — staff accept/reject; un-accepted bookings expire when review ends, then the batch runs.",
|
||||
},
|
||||
PAYMENT: {
|
||||
color: "blue",
|
||||
icon: Clock,
|
||||
text: "Payment window — selected bookings must pay before their countdown ends; unpaid slots pass to the waiting list.",
|
||||
},
|
||||
PRE_WINDOW: {
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
text: "Window not open yet — bookings are pre-ranked and will compete when it opens.",
|
||||
},
|
||||
CLOSED_FOR_DAY: {
|
||||
color: "gray",
|
||||
icon: Hourglass,
|
||||
text: "Window closed for the day — reopens for the next cycle if the train isn't full.",
|
||||
},
|
||||
DONE: {
|
||||
color: "gray",
|
||||
icon: CheckCircle2,
|
||||
text: "Booking cycles finished for this train.",
|
||||
},
|
||||
};
|
||||
const m = phase ? meta[phase] : null;
|
||||
if (!m) return null;
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Paper
|
||||
radius="md"
|
||||
p="sm"
|
||||
withBorder
|
||||
style={{
|
||||
background: cardVar(m.color, 0),
|
||||
borderColor: cardVar(m.color, 2),
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color={m.color} radius="md">
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={500} c={`${m.color}.8`}>
|
||||
{m.text}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriorityTrackingTab;
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
const message = (error as { response?: { data?: { message?: string | string[] } } })
|
||||
?.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join("; ");
|
||||
return message || (error as Error)?.message || fallback;
|
||||
};
|
||||
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
};
|
||||
|
||||
const DIRECTION_COLORS: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "teal",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
|
||||
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
|
||||
|
||||
function DirectionChip({ direction }: { direction: string }) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
|
||||
{DIRECTION_LABELS[direction] ?? direction}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingCell({ row }: { row: YardWorkBookingRow }) {
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm" fw={600}>
|
||||
{row.reference ?? row.id.slice(0, 8)}
|
||||
</Text>
|
||||
{row.isGovernment && (
|
||||
<Badge size="xs" variant="light" color="grape">
|
||||
GOV
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkTable({
|
||||
rows,
|
||||
side,
|
||||
trainHere,
|
||||
onLoad,
|
||||
onUnload,
|
||||
pendingBookingId,
|
||||
}: {
|
||||
rows: YardWorkBookingRow[];
|
||||
side: "load" | "unload";
|
||||
trainHere: boolean;
|
||||
onLoad: (bookingId: string) => void;
|
||||
onUnload: (bookingId: string) => void;
|
||||
pendingBookingId: string | null;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{side === "load" ? "No bookings board here." : "No bookings alight here."}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="xs" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Direction</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
|
||||
const canAct = side === "load" ? row.canLoad : row.canUnload;
|
||||
return (
|
||||
<Table.Tr key={row.id}>
|
||||
<Table.Td>
|
||||
<BookingCell row={row} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{row.customer}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<DirectionChip direction={row.tradeDirection} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={row.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{timestamp ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{fmtDate(timestamp)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{side === "load" ? (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo loaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.loadedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onLoad(row.id)}
|
||||
>
|
||||
Load
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={
|
||||
trainHere
|
||||
? "Confirm cargo unloaded at this yard"
|
||||
: "Train must be at this yard"
|
||||
}
|
||||
disabled={!canAct && Boolean(row.arrivedAt)}
|
||||
>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<PackageOpen size={13} />}
|
||||
disabled={!canAct || !trainHere}
|
||||
loading={pendingBookingId === row.id}
|
||||
onClick={() => onUnload(row.id)}
|
||||
>
|
||||
Unload
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard load/unload worklist for one schedule — every trade direction. Each
|
||||
* booking boards at its origin yard and alights at its destination yard; the
|
||||
* operator confirms both while the train's last recorded checkpoint is at that
|
||||
* yard (the server validates the position). Unloading stamps the booking's own
|
||||
* arrival — ARRIVED for import/export, COMPLETED for intercity.
|
||||
*/
|
||||
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const yardWorkQuery = useQuery(
|
||||
api.trainScheduling.yardWork.queryOptions({
|
||||
input: { scheduleId },
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
|
||||
});
|
||||
|
||||
const load = useMutation(
|
||||
api.trainScheduling.loadScheduleBooking.mutationOptions({
|
||||
onSuccess: () => {
|
||||
void invalidate();
|
||||
toast({ title: "Cargo loaded" });
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Load failed",
|
||||
description: parseError(err, "Could not confirm loading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const unload = useMutation(
|
||||
api.trainScheduling.unloadScheduleBooking.mutationOptions({
|
||||
onSuccess: (result) => {
|
||||
void invalidate();
|
||||
toast({
|
||||
title:
|
||||
result.status === "COMPLETED"
|
||||
? "Cargo unloaded — booking completed"
|
||||
: "Cargo unloaded — booking arrived",
|
||||
});
|
||||
},
|
||||
onError: (err) =>
|
||||
toast({
|
||||
title: "Unload failed",
|
||||
description: parseError(err, "Could not confirm unloading"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const data = yardWorkQuery.data;
|
||||
const yards: YardWorkYard[] = data?.yards ?? [];
|
||||
const trainAtYardId = data?.trainAtYardId ?? null;
|
||||
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
|
||||
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group gap="xs">
|
||||
<MapPin size={18} />
|
||||
<Text fw={700}>Yard load / unload</Text>
|
||||
</Group>
|
||||
|
||||
{yardWorkQuery.isLoading ? (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Loading yard worklists…
|
||||
</Text>
|
||||
</Group>
|
||||
) : yardWorkQuery.isError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
|
||||
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
|
||||
</Alert>
|
||||
) : yards.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No bookings are assigned to this schedule yet.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
What boards and alights at each stop. Confirm loading at a booking's
|
||||
origin and unloading at its destination while the train is at that
|
||||
yard — unloading stamps the booking's own arrival, even before the
|
||||
train's final stop.
|
||||
</Text>
|
||||
{yards.map((yard, index) => {
|
||||
const trainHere = trainAtYardId === yard.yardId;
|
||||
return (
|
||||
<Stack key={yard.yardId} gap="sm">
|
||||
{index > 0 && <Divider />}
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{yard.yard}</Text>
|
||||
{trainHere && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={12} />}
|
||||
>
|
||||
Train here
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Board here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toLoad}
|
||||
side="load"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingLoadId}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
Alight here
|
||||
</Text>
|
||||
<WorkTable
|
||||
rows={yard.toUnload}
|
||||
side="unload"
|
||||
trainHere={trainHere}
|
||||
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
|
||||
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
|
||||
pendingBookingId={pendingUnloadId}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
BatchBoardBookingDetail,
|
||||
BatchBoardScheduleDetail,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
/**
|
||||
* Client-side forecast of what the batch engine WOULD select if it ran right now.
|
||||
*
|
||||
* The real selection only happens once the document-review window closes and staff
|
||||
* hit "run batch". Before that, operations can only see the *current* per-booking
|
||||
* state (READY / SELECTED / …). This module simulates the engine's greedy fill so
|
||||
* the board can show the likely winners + waiting list live, during OPEN and
|
||||
* DOC_REVIEW, before anything is committed.
|
||||
*
|
||||
* It mirrors the engine (booking-batch.service): rank government-first, then
|
||||
* priority score desc, then oldest booked; greedily board each booking while it
|
||||
* fits ALL THREE capacity axes at once — wagon slots, max pull weight (tons), and
|
||||
* train length (metres). The first booking that busts any axis, and everyone after
|
||||
* it, drops to the waiting list. Purely a projection; the server stays the source
|
||||
* of truth for the real run.
|
||||
*/
|
||||
|
||||
export interface ForecastLimits {
|
||||
/** Wagon-slot cap (schedule.maxWagons), or null if unknown. */
|
||||
maxWagons: number | null;
|
||||
/** Locomotive max pull weight in tons, or null. */
|
||||
maxWeightTons: number | null;
|
||||
/** Max train length in metres, or null. */
|
||||
maxLengthMeters: number | null;
|
||||
}
|
||||
|
||||
/** Which capacity axis stopped a booking from boarding (for the "why not" hint). */
|
||||
export type BlockingAxis = "wagons" | "weight" | "length";
|
||||
|
||||
export interface ForecastRow {
|
||||
booking: BatchBoardBookingDetail;
|
||||
/** 1-based rank across the whole eligible pool. */
|
||||
rank: number;
|
||||
/** True → boards in the simulated batch; false → simulated waiting list. */
|
||||
selected: boolean;
|
||||
/** Cumulative wagons/weight/length AFTER this booking (only when selected). */
|
||||
cumulativeWagons: number;
|
||||
cumulativeWeightTons: number;
|
||||
cumulativeLengthMeters: number;
|
||||
/** If not selected, the first axis that would have overflowed. */
|
||||
blockedBy: BlockingAxis | null;
|
||||
}
|
||||
|
||||
export interface ForecastResult {
|
||||
rows: ForecastRow[];
|
||||
selected: ForecastRow[];
|
||||
waiting: ForecastRow[];
|
||||
/** Bookings excluded from the sim entirely (expired / no signed contract). */
|
||||
ineligible: BatchBoardBookingDetail[];
|
||||
limits: ForecastLimits;
|
||||
/** Totals of the simulated batch. */
|
||||
usedWagons: number;
|
||||
usedWeightTons: number;
|
||||
usedLengthMeters: number;
|
||||
/** True once any axis is at/over its cap — train is "full" in the sim. */
|
||||
full: boolean;
|
||||
}
|
||||
|
||||
/** Engine rank order: government first, then priority desc, then oldest booked. */
|
||||
export function rankBookings(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
): BatchBoardBookingDetail[] {
|
||||
const time = (b: BatchBoardBookingDetail) =>
|
||||
b.fullyExecutedAt
|
||||
? new Date(b.fullyExecutedAt).getTime()
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
return [...bookings].sort((a, b) => {
|
||||
if (a.isGovernment !== b.isGovernment) return a.isGovernment ? -1 : 1;
|
||||
if (b.priorityScore !== a.priorityScore)
|
||||
return b.priorityScore - a.priorityScore;
|
||||
return time(a) - time(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking can compete in the batch only once its contract is signed. Expired
|
||||
* bookings and pending-contract bookings never board, so they're pulled out of the
|
||||
* sim (surfaced separately so they don't vanish from the board).
|
||||
*/
|
||||
function isEligible(b: BatchBoardBookingDetail): boolean {
|
||||
return b.state !== "EXPIRED" && b.state !== "PENDING_CONTRACT";
|
||||
}
|
||||
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** Would adding `add` to `used` exceed `cap`? (cap null ⇒ axis unconstrained.) */
|
||||
function overflows(used: number, add: number, cap: number | null): boolean {
|
||||
return cap != null && used + add > cap;
|
||||
}
|
||||
|
||||
export function simulateBatch(
|
||||
bookings: BatchBoardBookingDetail[],
|
||||
limits: ForecastLimits,
|
||||
): ForecastResult {
|
||||
const ranked = rankBookings(bookings);
|
||||
const eligible = ranked.filter(isEligible);
|
||||
const ineligible = ranked.filter((b) => !isEligible(b));
|
||||
|
||||
const rows: ForecastRow[] = [];
|
||||
let wagons = 0;
|
||||
let weight = 0;
|
||||
let length = 0;
|
||||
// Once the train is full we stop boarding, but keep ranking the rest as waiting.
|
||||
let full = false;
|
||||
|
||||
eligible.forEach((booking, i) => {
|
||||
let blockedBy: BlockingAxis | null = null;
|
||||
if (!full) {
|
||||
if (overflows(wagons, booking.wagons, limits.maxWagons))
|
||||
blockedBy = "wagons";
|
||||
else if (overflows(weight, booking.weightTons, limits.maxWeightTons))
|
||||
blockedBy = "weight";
|
||||
else if (overflows(length, booking.lengthMeters, limits.maxLengthMeters))
|
||||
blockedBy = "length";
|
||||
}
|
||||
// Strict fill: the first booking that doesn't fit closes the train, so lower-
|
||||
// priority bookings can't leapfrog it even if they'd individually fit. Matches
|
||||
// the engine's greedy pass.
|
||||
const selected = !full && blockedBy === null;
|
||||
if (selected) {
|
||||
wagons += booking.wagons;
|
||||
weight = round2(weight + booking.weightTons);
|
||||
length = round2(length + booking.lengthMeters);
|
||||
} else {
|
||||
full = true;
|
||||
}
|
||||
rows.push({
|
||||
booking,
|
||||
rank: i + 1,
|
||||
selected,
|
||||
cumulativeWagons: selected ? wagons : 0,
|
||||
cumulativeWeightTons: selected ? weight : 0,
|
||||
cumulativeLengthMeters: selected ? length : 0,
|
||||
blockedBy: selected ? null : (blockedBy ?? firstBindingAxis(limits)),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
selected: rows.filter((r) => r.selected),
|
||||
waiting: rows.filter((r) => !r.selected),
|
||||
ineligible,
|
||||
limits,
|
||||
usedWagons: wagons,
|
||||
usedWeightTons: weight,
|
||||
usedLengthMeters: length,
|
||||
full,
|
||||
};
|
||||
}
|
||||
|
||||
/** When the train closed on an earlier booking, name the tightest axis for the hint. */
|
||||
function firstBindingAxis(limits: ForecastLimits): BlockingAxis {
|
||||
if (limits.maxWagons != null) return "wagons";
|
||||
if (limits.maxWeightTons != null) return "weight";
|
||||
return "length";
|
||||
}
|
||||
|
||||
/** Pull the three capacity caps off the board detail response. */
|
||||
export function limitsFromDetail(
|
||||
data: BatchBoardScheduleDetail,
|
||||
): ForecastLimits {
|
||||
return {
|
||||
maxWagons: data.capacity.maxWagons ?? null,
|
||||
maxWeightTons:
|
||||
data.capacity.maxWeightTons ??
|
||||
data.locomotive?.maxPullWeightTons ??
|
||||
null,
|
||||
maxLengthMeters:
|
||||
data.capacity.maxLengthMeters ??
|
||||
data.locomotive?.maxTrainLengthMeters ??
|
||||
null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The forecast is meaningful before the batch is committed — i.e. while bookings
|
||||
* are still being taken or reviewed. Once the engine has run (PAYMENT onward) the
|
||||
* real per-booking state is the truth, so we stop showing the projection.
|
||||
*/
|
||||
export function forecastIsLive(
|
||||
phase: BatchBoardScheduleDetail["windowPhase"],
|
||||
): boolean {
|
||||
return phase === "PRE_WINDOW" || phase === "OPEN" || phase === "DOC_REVIEW";
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileText } from 'lucide-react';
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type ContainerItem,
|
||||
type ContainerItemStage,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface ContainerItemsModalProps {
|
||||
@@ -36,6 +37,7 @@ const STAGE_TABS: Array<{ value: string; label: string }> = [
|
||||
{ value: 'ALL', label: 'All' },
|
||||
{ value: 'RECEIVED', label: 'Received' },
|
||||
{ value: 'GRN', label: "GRN'd" },
|
||||
{ value: 'ASSIGNED', label: 'Assigned' },
|
||||
{ value: 'LOADED', label: 'Loaded' },
|
||||
{ value: 'LEFT', label: 'Left' },
|
||||
{ value: 'DELIVERED', label: 'Delivered' },
|
||||
@@ -45,13 +47,15 @@ const STAGE_COLOR: Record<ContainerItemStage, string> = {
|
||||
PENDING: 'gray',
|
||||
RECEIVED: 'blue',
|
||||
GRN: 'teal',
|
||||
ASSIGNED: 'indigo',
|
||||
LOADED: 'grape',
|
||||
LEFT: 'orange',
|
||||
DELIVERED: 'green',
|
||||
};
|
||||
|
||||
/** Loadable = not yet on a truck (before LOADED). */
|
||||
const isLoadable = (i: ContainerItem) => i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN';
|
||||
/** Loadable = not yet loaded (PENDING/RECEIVED/GRN, or customer-ASSIGNED awaiting load). */
|
||||
const isLoadable = (i: ContainerItem) =>
|
||||
i.stage === 'PENDING' || i.stage === 'RECEIVED' || i.stage === 'GRN' || i.stage === 'ASSIGNED';
|
||||
|
||||
export function ContainerItemsModal({ opened, onClose, bookingId, bookingReference }: ContainerItemsModalProps) {
|
||||
const { toast } = useToast();
|
||||
@@ -76,8 +80,13 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
|
||||
[items, tab],
|
||||
);
|
||||
// Only arrived, not-yet-departed trucks can be loaded.
|
||||
const truckOptions = trucks
|
||||
.filter((t) => !(t as { departedAt?: string }).departedAt)
|
||||
.filter(
|
||||
(t) =>
|
||||
Boolean((t as { arrivedAt?: string }).arrivedAt) &&
|
||||
!(t as { departedAt?: string }).departedAt,
|
||||
)
|
||||
.map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
|
||||
|
||||
const loadMutation = useMutation({
|
||||
@@ -90,12 +99,29 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||
});
|
||||
|
||||
const requestSign = async () => {
|
||||
try {
|
||||
const res = await warehouseService.requestHandoverSignature(bookingId as string);
|
||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||
if (res.alreadySigned) {
|
||||
toast({ title: 'Handover already signed', description: 'You can generate the exit paper now.' });
|
||||
} else {
|
||||
toast({
|
||||
title: 'Handover not signed',
|
||||
description: `Signature request sent to the customer${res.reference ? ` (${res.reference})` : ''}.`,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Could not request signature', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const openExitPaper = async (assignmentId: string, plate: string) => {
|
||||
try {
|
||||
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
|
||||
openPdfBlob(res.data, `exit-${plate}.pdf`);
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper not ready', description: await extractDownloadErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,16 +189,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
|
||||
<Table.Td>{i.contractId ? <Badge variant="outline" color="indigo">Contract</Badge> : '—'}</Table.Td>
|
||||
<Table.Td>{i.hasLastMile ? <Badge variant="light" color="cyan">EDR</Badge> : <Badge variant="light" color="gray">Self-haul</Badge>}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
{i.truckAssignmentId && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() => openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')}
|
||||
{i.loaded && i.truckAssignmentId && (
|
||||
<Tooltip
|
||||
label="Sign the handover first — a truck can't get its exit paper until the handover is signed."
|
||||
disabled={i.handoverSigned}
|
||||
withArrow
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color={i.handoverSigned ? 'orange' : 'gray'}
|
||||
leftSection={<FileText size={13} />}
|
||||
onClick={() =>
|
||||
i.handoverSigned
|
||||
? openExitPaper(i.truckAssignmentId as string, i.truckPlate ?? '')
|
||||
: requestSign()
|
||||
}
|
||||
>
|
||||
Exit Paper
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -102,7 +102,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
await updateMutation.mutateAsync({ id: warehouse.id, payload: { ...payload, status: form.status } });
|
||||
toast({ title: 'Warehouse updated' });
|
||||
} else {
|
||||
await createMutation.mutateAsync(payload);
|
||||
await createMutation.mutateAsync({ ...payload, status: form.status });
|
||||
toast({ title: 'Warehouse created' });
|
||||
}
|
||||
onClose();
|
||||
@@ -149,15 +149,13 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
onChange={(value) => setForm((f) => ({ ...f, type: (value as WarehouseType) ?? 'OPEN_WAREHOUSE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
{isEdit && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={statusOptions}
|
||||
value={form.status}
|
||||
onChange={(value) => setForm((f) => ({ ...f, status: (value as 'ACTIVE' | 'INACTIVE') ?? 'ACTIVE' }))}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
@@ -169,7 +167,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function CreateYardModal({ opened, onClose, warehouseId, yard }: CreateYa
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -132,7 +132,7 @@ export function CreateZoneModal({ opened, onClose, yardId, zone }: CreateZoneMod
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Capacity weight (kg)"
|
||||
label="Capacity weight (t)"
|
||||
placeholder="Optional"
|
||||
min={0}
|
||||
value={form.capacityWeight}
|
||||
|
||||
@@ -174,13 +174,13 @@ export function InspectionReportModal({ opened, onClose, inventoryId }: Inspecti
|
||||
{hasWeightLoss && (
|
||||
<Group grow mt="xs">
|
||||
<NumberInput
|
||||
label="Expected weight (kg)"
|
||||
label="Expected weight (t)"
|
||||
min={0}
|
||||
value={expectedWeight}
|
||||
onChange={(v) => setExpectedWeight(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual weight (kg)"
|
||||
label="Actual weight (t)"
|
||||
min={0}
|
||||
value={actualWeight}
|
||||
onChange={(v) => setActualWeight(v === '' ? '' : Number(v))}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
|
||||
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
|
||||
<DetailRow label="Handover reference" value={handoverReference || '-'} />
|
||||
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(item.weight)} t`} />
|
||||
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />
|
||||
</SimpleGrid>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function InventoryInquiryDetailModal({ opened, onClose, result }: Invento
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<DetailRow label="Item" value={itemLabel(result)} />
|
||||
<DetailRow label="Quantity" value={formatNumber(result.quantity)} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} kg`} />
|
||||
<DetailRow label="Weight" value={`${formatNumber(result.weight)} t`} />
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider label="Location" labelPosition="left" />
|
||||
|
||||
@@ -17,7 +17,6 @@ import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -29,12 +28,11 @@ interface InventoryWorkbenchProps {
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
/** Inventory table + all lifecycle actions (advance / move / history). */
|
||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [reserveItem, setReserveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadItem, setLoadItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -171,7 +169,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
const storeInventory = async (item: WarehouseInventoryItem) => {
|
||||
setBusyId(item.id);
|
||||
try {
|
||||
const stored = await storeMutation.mutateAsync(item.id);
|
||||
const stored = await storeMutation.mutateAsync({ id: item.id });
|
||||
toast({
|
||||
title: 'Inventory stored',
|
||||
description: [stored.warehouse?.code, stored.yard?.code, stored.zone?.code].filter(Boolean).join(' / '),
|
||||
@@ -187,9 +185,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
switch (action) {
|
||||
case 'store':
|
||||
return storeInventory(item);
|
||||
case 'reserve':
|
||||
setReserveItem(item);
|
||||
return;
|
||||
case 'ready-for-loading':
|
||||
return runDirect(item, () => readyMutation.mutateAsync(item.id), 'Ready for loading');
|
||||
case 'load':
|
||||
@@ -258,11 +253,6 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
opened={Boolean(reserveItem)}
|
||||
onClose={() => setReserveItem(null)}
|
||||
item={reserveItem}
|
||||
/>
|
||||
<LoadInventoryModal opened={Boolean(loadItem)} onClose={() => setLoadItem(null)} item={loadItem} />
|
||||
<InventoryHistoryModal
|
||||
opened={Boolean(historyItem)}
|
||||
|
||||
@@ -67,7 +67,7 @@ export function LoadInventoryModal({ opened, onClose, item }: LoadInventoryModal
|
||||
<WagonSelect label="Wagon" required value={wagonId} onChange={setWagonId} />
|
||||
|
||||
<NumberInput
|
||||
label="Loaded weight (kg)"
|
||||
label="Loaded weight (t)"
|
||||
placeholder="Defaults to item weight"
|
||||
min={0}
|
||||
value={loadedWeight}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { TrainFront } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, TrainFront } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService, type TrainLoadableItem } from '@/services/warehouse.service';
|
||||
import {
|
||||
warehouseService,
|
||||
type LoadableTrain,
|
||||
type TrainLoadableItem,
|
||||
} from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
@@ -27,226 +31,277 @@ const STAGE_COLOR: Record<string, string> = {
|
||||
LOADED: 'green',
|
||||
};
|
||||
|
||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} kg`);
|
||||
const weight = (w: number | null) => (w == null ? '—' : `${Number(w).toLocaleString()} t`);
|
||||
|
||||
interface BookingGroup {
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
items: TrainLoadableItem[];
|
||||
}
|
||||
|
||||
function groupByBooking(items: TrainLoadableItem[]): BookingGroup[] {
|
||||
const map = new Map<string, BookingGroup>();
|
||||
for (const i of items) {
|
||||
const key = i.bookingId ?? i.bookingReference ?? 'unknown';
|
||||
let g = map.get(key);
|
||||
if (!g) {
|
||||
g = { bookingId: i.bookingId, bookingReference: i.bookingReference, customerName: i.customerName, items: [] };
|
||||
map.set(key, g);
|
||||
}
|
||||
g.items.push(i);
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load to Train — pick an allocated EXPORT train, see the arrived containers/cargoes
|
||||
* assigned to it (stage tabs), multiselect the ready ones and load them onto their
|
||||
* already-allocated wagons. Loading follows train + wagon allocation: only items
|
||||
* that are READY_FOR_LOADING and have an allocated wagon are selectable.
|
||||
* Load to Train — a datatable of allocated EXPORT trains. Expand a train to see
|
||||
* the bookings allocated to it; expand a booking to see its containers/cargoes
|
||||
* and load the ready ones onto their wagons. Only READY_FOR_LOADING items with an
|
||||
* allocated wagon are selectable.
|
||||
*/
|
||||
export function LoadToTrainPanel() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [scheduleId, setScheduleId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState('received');
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const trainsKey = ['loadable-trains'];
|
||||
const { data: trains = [], isLoading: trainsLoading } = useQuery({
|
||||
queryKey: trainsKey,
|
||||
const { data: trains = [], isLoading } = useQuery({
|
||||
queryKey: ['loadable-trains'],
|
||||
queryFn: () => warehouseService.getLoadableTrains(),
|
||||
});
|
||||
|
||||
const itemsKey = ['train-loadable-items', scheduleId];
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: itemsKey,
|
||||
queryFn: () => warehouseService.getTrainLoadableItems(scheduleId as string),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
|
||||
const received = useMemo(() => items.filter((i) => i.status !== 'LOADED'), [items]);
|
||||
const loaded = useMemo(() => items.filter((i) => i.status === 'LOADED'), [items]);
|
||||
const visible = tab === 'loaded' ? loaded : received;
|
||||
|
||||
const trainOptions = trains.map((t) => ({
|
||||
value: t.scheduleId,
|
||||
label:
|
||||
`${t.trainNumber ?? t.scheduleId.slice(0, 8)}` +
|
||||
(t.origin || t.destination ? ` · ${t.origin ?? '?'}→${t.destination ?? '?'}` : '') +
|
||||
` · ${t.readyCount} ready / ${t.loadedCount} loaded`,
|
||||
}));
|
||||
|
||||
const selectableVisible = visible.filter((i) => i.loadable);
|
||||
const allSelected =
|
||||
selectableVisible.length > 0 && selectableVisible.every((i) => selected.includes(i.id));
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const toggle = (id: string) =>
|
||||
setExpanded((s) => {
|
||||
const next = new Set(s);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (trains.length === 0) {
|
||||
return (
|
||||
<Alert color="gray" variant="light">
|
||||
No allocated EXPORT trains awaiting loading. Trains appear here after train and wagon allocation.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Train</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th ta="center">Ready</Table.Th>
|
||||
<Table.Th ta="center">Loaded</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trains.map((t) => (
|
||||
<TrainRow
|
||||
key={t.scheduleId}
|
||||
train={t}
|
||||
expanded={expanded.has(t.scheduleId)}
|
||||
onToggle={() => toggle(t.scheduleId)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function TrainRow({ train, expanded, onToggle }: { train: LoadableTrain; expanded: boolean; onToggle: () => void }) {
|
||||
const { data: items = [], isLoading } = useQuery({
|
||||
queryKey: ['train-loadable-items', train.scheduleId],
|
||||
queryFn: () => warehouseService.getTrainLoadableItems(train.scheduleId),
|
||||
enabled: expanded,
|
||||
});
|
||||
const bookings = useMemo(() => groupByBooking(items), [items]);
|
||||
const route =
|
||||
train.origin || train.destination ? `${train.origin ?? '?'} → ${train.destination ?? '?'}` : '—';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table.Tr style={{ cursor: 'pointer' }} onClick={onToggle}>
|
||||
<Table.Td>{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TrainFront size={16} />
|
||||
<Text fw={600}>{train.trainNumber ?? train.scheduleId.slice(0, 8)}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{route}</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge color="blue" variant="light">
|
||||
{train.readyCount}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge color="green" variant="light">
|
||||
{train.loadedCount}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{expanded && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5} p={0}>
|
||||
<Box p="sm" bg="var(--mantine-color-gray-0)">
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : bookings.length === 0 ? (
|
||||
<Alert color="gray" variant="light">
|
||||
No arrived containers/cargoes allocated to this train yet.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{bookings.map((b) => (
|
||||
<BookingBlock key={b.bookingId ?? b.bookingReference} scheduleId={train.scheduleId} booking={b} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingBlock({ scheduleId, booking }: { scheduleId: string; booking: BookingGroup }) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const loadedCount = booking.items.filter((i) => i.status === 'LOADED').length;
|
||||
const selectable = booking.items.filter((i) => i.loadable);
|
||||
const allSelected = selectable.length > 0 && selectable.every((i) => selected.includes(i.id));
|
||||
const toggleItem = (id: string) =>
|
||||
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
|
||||
const toggleAll = () =>
|
||||
setSelected((s) =>
|
||||
allSelected
|
||||
? s.filter((id) => !selectableVisible.some((i) => i.id === id))
|
||||
: Array.from(new Set([...s, ...selectableVisible.map((i) => i.id)])),
|
||||
allSelected ? s.filter((id) => !selectable.some((i) => i.id === id)) : selectable.map((i) => i.id),
|
||||
);
|
||||
|
||||
const loadMutation = useMutation({
|
||||
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId as string, selected),
|
||||
mutationFn: () => warehouseService.loadItemsOntoTrain(scheduleId, selected),
|
||||
onSuccess: (r) => {
|
||||
queryClient.invalidateQueries({ queryKey: itemsKey });
|
||||
queryClient.invalidateQueries({ queryKey: trainsKey });
|
||||
queryClient.invalidateQueries({ queryKey: ['train-loadable-items', scheduleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['loadable-trains'] });
|
||||
setSelected([]);
|
||||
toast({
|
||||
title: 'Loaded onto train',
|
||||
description: `Loaded ${r.loadedCount} item(s); skipped ${r.skippedCount}.`,
|
||||
});
|
||||
toast({ title: 'Loaded onto train', description: `Loaded ${r.loadedCount}; skipped ${r.skippedCount}.` });
|
||||
},
|
||||
onError: (e) =>
|
||||
toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
|
||||
});
|
||||
|
||||
const renderRow = (i: TrainLoadableItem) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
checked={selected.includes(i.id)}
|
||||
onChange={() => toggle(i.id)}
|
||||
disabled={!i.loadable}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{weight(i.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
|
||||
{i.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.wagonNumber ? (
|
||||
<Badge variant="outline" color="indigo">
|
||||
{i.wagonNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
Not allocated
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{i.bookingReference ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{i.inspectionStatus ? (
|
||||
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||
{i.inspectionStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-end" justify="space-between">
|
||||
<Select
|
||||
label="Train"
|
||||
description="Allocated EXPORT trains awaiting loading"
|
||||
placeholder={trainsLoading ? 'Loading trains…' : trainOptions.length ? 'Select a train' : 'No trains to load'}
|
||||
data={trainOptions}
|
||||
value={scheduleId}
|
||||
onChange={(v) => {
|
||||
setScheduleId(v);
|
||||
setSelected([]);
|
||||
setTab('received');
|
||||
}}
|
||||
disabled={trainOptions.length === 0}
|
||||
leftSection={<TrainFront size={16} />}
|
||||
w={460}
|
||||
searchable
|
||||
/>
|
||||
<Paper withBorder radius="sm" p="xs">
|
||||
<Group justify="space-between" style={{ cursor: 'pointer' }} onClick={() => setOpen((o) => !o)} wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{open ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
<Text fw={600}>{booking.bookingReference ?? booking.bookingId?.slice(0, 8) ?? '—'}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{booking.customerName ?? '—'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge variant="light" color="blue">
|
||||
{booking.items.length} item(s)
|
||||
</Badge>
|
||||
{loadedCount > 0 && (
|
||||
<Badge variant="light" color="green">
|
||||
{loadedCount} loaded
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!scheduleId ? (
|
||||
<Alert color="gray" variant="light">
|
||||
Pick a train to see the arrived containers/cargoes allocated to it. Items appear here only
|
||||
after train and wagon allocation.
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(v) => setTab(v ?? 'received')}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="received"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{received.length}
|
||||
{open && (
|
||||
<>
|
||||
<Table striped highlightOnHover verticalSpacing="xs" mt="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={36}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && selected.length > 0}
|
||||
onChange={toggleAll}
|
||||
disabled={selectable.length === 0}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Container / Cargo</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{booking.items.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>
|
||||
<Checkbox checked={selected.includes(i.id)} onChange={() => toggleItem(i.id)} disabled={!i.loadable} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600}>{i.containerNumber ?? i.cargoType ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{i.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{weight(i.weight)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STAGE_COLOR[i.status] ?? 'gray'} variant="light">
|
||||
{i.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Received
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="loaded"
|
||||
rightSection={
|
||||
<Badge size="xs" variant="light" color="green">
|
||||
{loaded.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
Loaded
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : visible.length === 0 ? (
|
||||
<Alert color="gray" variant="light">
|
||||
{tab === 'loaded' ? 'Nothing loaded onto this train yet.' : 'No arrived items ready for this train.'}
|
||||
</Alert>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>
|
||||
{tab === 'received' && (
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={!allSelected && selected.length > 0}
|
||||
onChange={toggleAll}
|
||||
disabled={selectableVisible.length === 0}
|
||||
/>
|
||||
)}
|
||||
</Table.Th>
|
||||
<Table.Th>Container / Cargo</Table.Th>
|
||||
<Table.Th>Goods</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Stage</Table.Th>
|
||||
<Table.Th>Wagon</Table.Th>
|
||||
<Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{visible.map(renderRow)}</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
{tab === 'received' && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{selected.length} selected · only READY_FOR_LOADING items with an allocated wagon can be loaded
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={16} />}
|
||||
disabled={selected.length === 0}
|
||||
loading={loadMutation.isPending}
|
||||
onClick={() => loadMutation.mutate()}
|
||||
>
|
||||
Load {selected.length || ''} onto train
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.wagonNumber ? (
|
||||
<Badge variant="outline" color="indigo">
|
||||
{i.wagonNumber}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" c="red">
|
||||
Not allocated
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{i.inspectionStatus ? (
|
||||
<Badge size="xs" variant="light" color={i.inspectionStatus === 'PASSED' ? 'green' : 'orange'}>
|
||||
{i.inspectionStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Group justify="space-between" align="center" mt="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{selected.length} selected · only READY_FOR_LOADING items with a wagon can be loaded
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
leftSection={<TrainFront size={14} />}
|
||||
disabled={selected.length === 0}
|
||||
loading={loadMutation.isPending}
|
||||
onClick={() => loadMutation.mutate()}
|
||||
>
|
||||
Load {selected.length || ''} onto train
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Checkbox,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
ScrollArea,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ClipboardCheck,
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
FileText,
|
||||
History,
|
||||
Info,
|
||||
MapPin,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackageOpen,
|
||||
PackageSearch,
|
||||
@@ -61,7 +65,6 @@ import type {
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { TruckDispatchModal } from './TruckDispatchModal';
|
||||
import { ContainerItemsModal } from './ContainerItemsModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
@@ -69,7 +72,9 @@ import { InventoryDetailModal } from './InventoryDetailModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
@@ -520,14 +525,14 @@ function TruckEntranceFields({
|
||||
{value.weighingRequired && (
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
label="Gross weight (t)"
|
||||
required
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
label="Exit tare weight (t)"
|
||||
required
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
@@ -596,7 +601,7 @@ function TruckEntranceFields({
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
label="Net weight (t)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
@@ -2182,7 +2187,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
@@ -2192,8 +2196,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [containerItemsItem, setContainerItemsItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [storeItem, setStoreItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
@@ -2413,49 +2418,16 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
{/* Primary stage action stays visible; the rest live under the kebab. */}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && r.hasAssignedTruck && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||
color="yellow"
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||
>
|
||||
Ready Pickup
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busyId === r.id}
|
||||
onClick={() => setLoadTruckItem(toInventoryItem(r))}
|
||||
>
|
||||
Truck_dispatch
|
||||
{r.releaseOrderReference ? 'Truck Leaving' : 'Truck Arrival'}
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
@@ -2470,40 +2442,64 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
Exit Paper
|
||||
</Button>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||
>
|
||||
Deliver
|
||||
</Button>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<FileText size={14} />}
|
||||
onClick={() => openHandoverDocument(r)}
|
||||
>
|
||||
{r.handoverDocumentReference ? 'View Handover' : 'Handover'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
<Tooltip label="Storage / fee preview" withArrow>
|
||||
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
<PackageCheck size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="History" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
<History size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Menu shadow="md" width={240} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" aria-label="More actions" loading={busyId === r.id}>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{r.currentStatus === 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<MapPin size={14} />} onClick={() => setStoreItem(toInventoryItem(r))}>
|
||||
Store…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus !== 'UNLOADED' && (
|
||||
<Menu.Item leftSection={<ArrowRightLeft size={14} />} onClick={() => setMoveItem(toInventoryItem(r))}>
|
||||
Move…
|
||||
</Menu.Item>
|
||||
)}
|
||||
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}>
|
||||
Ready for pickup
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={14} />}
|
||||
disabled={!r.hasAssignedTruck}
|
||||
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||
>
|
||||
{r.hasAssignedTruck
|
||||
? r.releaseOrderReference
|
||||
? 'Truck leaving'
|
||||
: 'Truck arrival'
|
||||
: 'Truck arrival — assign a truck first'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openReleaseDocument(r)}>
|
||||
Exit paper
|
||||
</Menu.Item>
|
||||
)}
|
||||
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||
<Menu.Item onClick={() => setDeliverItem(toInventoryItem(r))}>Deliver</Menu.Item>
|
||||
)}
|
||||
{r.inspectionStatus === 'PASSED' && (
|
||||
<Menu.Item leftSection={<FileText size={14} />} onClick={() => openHandoverDocument(r)}>
|
||||
{r.handoverDocumentReference ? 'View handover' : 'Handover'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item onClick={() => setInspectId(r.id)}>Inspect / report</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<PackageCheck size={14} />} onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||
Storage / fee preview
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<History size={14} />} onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||
History
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -2526,13 +2522,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<StoreInventoryModal opened={Boolean(storeItem)} onClose={() => setStoreItem(null)} item={storeItem} />
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
<TruckDispatchModal
|
||||
opened={Boolean(loadTruckItem)}
|
||||
onClose={() => setLoadTruckItem(null)}
|
||||
bookingId={loadTruckItem?.booking?.id ?? null}
|
||||
bookingReference={loadTruckItem?.booking?.reference ?? null}
|
||||
/>
|
||||
<ContainerItemsModal
|
||||
opened={Boolean(containerItemsItem)}
|
||||
onClose={() => setContainerItemsItem(null)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -28,29 +28,6 @@ export interface ReleaseOrderTruckPrefill {
|
||||
containerNumber?: string | null;
|
||||
}
|
||||
|
||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
||||
].map(([powerPlate, trailerPlate], index) => ({
|
||||
value: powerPlate,
|
||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
||||
trailerPlate,
|
||||
}));
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
@@ -78,7 +55,7 @@ const lineValue = (notes: string | null | undefined, label: string) => {
|
||||
};
|
||||
|
||||
const lineNumber = (notes: string | null | undefined, label: string): number | '' => {
|
||||
const value = lineValue(notes, label).replace(/\s*kg$/i, '');
|
||||
const value = lineValue(notes, label).replace(/\s*(kg|t)$/i, '');
|
||||
if (!value) return '';
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : '';
|
||||
@@ -141,6 +118,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
// Per-container cargo weights — the truck's net (gross − tare) must equal the
|
||||
// total cargo weight of the containers selected as loaded on it.
|
||||
const { data: containerWeights = [] } = useQuery({
|
||||
queryKey: ['release-container-weights', bookingId],
|
||||
queryFn: () => warehouseService.getContainerWeights(bookingId as string),
|
||||
enabled: opened && Boolean(bookingId),
|
||||
});
|
||||
const [reference, setReference] = useState('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
@@ -210,22 +194,39 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
truckType: t.truckType,
|
||||
})),
|
||||
];
|
||||
const truckSelectOptions = [
|
||||
...assignedTruckOptions,
|
||||
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
|
||||
value: t.value,
|
||||
label: t.label,
|
||||
trailerPlate: t.trailerPlate,
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
truckType: '',
|
||||
})),
|
||||
];
|
||||
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
|
||||
// portal) are selectable. No global fleet list — if nothing is assigned, the
|
||||
// operator types the plate manually in the field below.
|
||||
const truckSelectOptions = assignedTruckOptions;
|
||||
// Neither a last-mile truck nor a customer truck has been assigned yet.
|
||||
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
|
||||
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
|
||||
// Which containers ride this truck, and their combined cargo weight. When the
|
||||
// booking has container weights, that sum is the authoritative net; the
|
||||
// operator selects the containers loaded on the truck at exit.
|
||||
const hasContainerWeights = containerWeights.length > 0;
|
||||
const containerWeightByNumber = new Map(
|
||||
containerWeights.map((c) => [c.containerNumber.toUpperCase(), Number(c.weightTons) || 0]),
|
||||
);
|
||||
const containerSelectData = containerWeights.map((c) => ({
|
||||
value: c.containerNumber,
|
||||
label: `${c.containerNumber} · ${(Number(c.weightTons) || 0).toLocaleString()} t`,
|
||||
}));
|
||||
const selectedContainerNumbers = containerNumbers.map((n) => n.trim()).filter(Boolean);
|
||||
const selectedCargoWeight = Number(
|
||||
selectedContainerNumbers
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
.toFixed(3),
|
||||
);
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
: item?.weight == null
|
||||
? netWeight
|
||||
: Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
@@ -246,6 +247,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
@@ -336,23 +341,27 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below.
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
placeholder="Select truck or type plate manually below"
|
||||
searchable
|
||||
clearable
|
||||
data={truckSelectOptions}
|
||||
disabled={isTruckIdentityLocked}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
}}
|
||||
/>
|
||||
{truckSelectOptions.length > 0 && (
|
||||
<Select
|
||||
label="Assigned first / last-mile truck"
|
||||
placeholder="Select the assigned truck"
|
||||
searchable
|
||||
clearable
|
||||
// Enabled at arrival so the operator picks which assigned truck came;
|
||||
// only locked on the exit (leaving) step once identity is captured.
|
||||
disabled={isEntranceLocked}
|
||||
data={truckSelectOptions}
|
||||
value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = truckSelectOptions.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
if (truck?.driverName) setDriverName(truck.driverName);
|
||||
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
|
||||
if (truck?.truckType) setTruckType(truck.truckType);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
@@ -376,34 +385,55 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
<Group grow align="flex-start">
|
||||
{hasContainerWeights ? (
|
||||
<MultiSelect
|
||||
label="Containers on this truck"
|
||||
description={
|
||||
isExitStep
|
||||
? 'Select the containers loaded on this truck — their cargo weight must match gross − tare.'
|
||||
: 'Containers this truck will carry.'
|
||||
}
|
||||
placeholder="Select containers"
|
||||
searchable
|
||||
data={containerSelectData}
|
||||
value={selectedContainerNumbers}
|
||||
onChange={(values) => setContainerNumbers(values.length ? values : [''])}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap={6}>
|
||||
<SimpleGrid cols={containerNumbers.length > 1 ? 2 : 1} spacing="sm">
|
||||
{containerNumbers.map((containerNumber, index) => (
|
||||
<TextInput
|
||||
key={index}
|
||||
label={containerNumbers.length > 1 ? `Container number ${index + 1}` : 'Container number'}
|
||||
value={containerNumber}
|
||||
onChange={(e) =>
|
||||
setContainerNumbers((numbers) =>
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (kg)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput label="Recorded net weight (system kg)" min={0} value={systemNetWeight} readOnly />
|
||||
<NumberInput label="Tare weight (t)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
value={systemNetWeight}
|
||||
readOnly
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep} />
|
||||
</Group>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Select, Stack, Text } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface StoreInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an unloaded import item. The operator may pick warehouse → yard → zone
|
||||
* explicitly; leaving them blank falls back to the backend auto allocation.
|
||||
*/
|
||||
export function StoreInventoryModal({ opened, onClose, item }: StoreInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||
const [warehouseId, setWarehouseId] = useState('');
|
||||
const [yardId, setYardId] = useState('');
|
||||
const [zoneId, setZoneId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setWarehouseId('');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const warehousesQuery = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } } }),
|
||||
);
|
||||
const yardsQuery = useQuery(
|
||||
api.warehouses.listYards.queryOptions({
|
||||
input: { warehouseId },
|
||||
enabled: Boolean(warehouseId),
|
||||
}),
|
||||
);
|
||||
const zonesQuery = useQuery(
|
||||
api.warehouses.listZones.queryOptions({
|
||||
input: { yardId },
|
||||
enabled: Boolean(yardId),
|
||||
}),
|
||||
);
|
||||
|
||||
const warehouseOptions = useMemo(
|
||||
() => (warehousesQuery.data ?? []).map((w) => ({ value: w.id, label: `${w.name} (${w.code})` })),
|
||||
[warehousesQuery.data],
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() => (yardsQuery.data ?? []).filter((y) => y.status === 'ACTIVE').map((y) => ({ value: y.id, label: `${y.name} (${y.code})` })),
|
||||
[yardsQuery.data],
|
||||
);
|
||||
const zoneOptions = useMemo(
|
||||
() => (zonesQuery.data ?? []).filter((z) => z.status === 'ACTIVE').map((z) => ({ value: z.id, label: `${z.name} (${z.code})` })),
|
||||
[zonesQuery.data],
|
||||
);
|
||||
|
||||
const isManual = Boolean(warehouseId || yardId || zoneId);
|
||||
const manualComplete = Boolean(warehouseId && yardId && zoneId);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (isManual && !manualComplete) {
|
||||
toast({ variant: 'destructive', title: 'Pick warehouse, yard and zone — or clear all to auto-allocate' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await storeMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: manualComplete ? { warehouseId, yardId, zoneId } : undefined,
|
||||
});
|
||||
toast({ title: manualComplete ? 'Inventory stored at selected location' : 'Inventory stored (auto-allocated)' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Store failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Store inventory" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Choose a warehouse, yard and zone to store this item at a specific location, or leave them
|
||||
blank to let the system auto-allocate by rule / available capacity.
|
||||
</Text>
|
||||
</Alert>
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Auto-allocate"
|
||||
searchable
|
||||
clearable
|
||||
data={warehouseOptions}
|
||||
value={warehouseId || null}
|
||||
onChange={(v) => {
|
||||
setWarehouseId(v ?? '');
|
||||
setYardId('');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={!warehouseId ? 'Select a warehouse first' : 'Select yard'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!warehouseId}
|
||||
data={yardOptions}
|
||||
value={yardId || null}
|
||||
onChange={(v) => {
|
||||
setYardId(v ?? '');
|
||||
setZoneId('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={!yardId ? 'Select a yard first' : 'Select zone'}
|
||||
searchable
|
||||
clearable
|
||||
disabled={!yardId}
|
||||
data={zoneOptions}
|
||||
value={zoneId || null}
|
||||
onChange={(v) => setZoneId(v ?? '')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={storeMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={storeMutation.isPending}>
|
||||
{manualComplete ? 'Store here' : 'Store (auto)'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,6 @@ const itemKind = (item: WarehouseInventoryItem) => {
|
||||
|
||||
const actionColor: Record<InventoryAction, string> = {
|
||||
store: 'blue',
|
||||
reserve: 'grape',
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
|
||||
@@ -57,3 +57,24 @@ export const extractErrorMessage = (error: unknown, fallback = 'Something went w
|
||||
const rawMessage = data?.message ?? data?.error ?? (error as Error)?.message;
|
||||
return Array.isArray(rawMessage) ? rawMessage.join(', ') : rawMessage ? String(rawMessage) : fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Error extractor for blob-download requests. When `responseType: 'blob'`, axios
|
||||
* delivers the JSON error body as a Blob, so `extractErrorMessage` can't read
|
||||
* `.message`. Decode the Blob to text, parse it, then fall back to the sync path.
|
||||
*/
|
||||
export const extractDownloadErrorMessage = async (error: unknown, fallback = 'Something went wrong') => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
if (responseData instanceof Blob) {
|
||||
try {
|
||||
const text = await responseData.text();
|
||||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||||
const raw = parsed?.message ?? parsed?.error;
|
||||
if (Array.isArray(raw)) return raw.join(', ');
|
||||
if (raw) return String(raw);
|
||||
} catch {
|
||||
/* not JSON — fall through */
|
||||
}
|
||||
}
|
||||
return extractErrorMessage(error, fallback);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ export const QUERY_KEYS = {
|
||||
documents: (id: string) =>
|
||||
["customers", "detail", id, "documents"] as const,
|
||||
payments: (id: string) => ["customers", "detail", id, "payments"] as const,
|
||||
changeRequests: (id: string) =>
|
||||
["customers", "detail", id, "change-requests"] as const,
|
||||
},
|
||||
|
||||
INVOICES: {
|
||||
|
||||
@@ -76,6 +76,12 @@ export const URL_CONSTANTS = {
|
||||
DOCUMENTS: (id: string) => `/companies/${id}/documents`,
|
||||
PROFILE_STATUS: (profileId: string) =>
|
||||
`/companies/company-profiles/${profileId}/status`,
|
||||
CHANGE_REQUESTS: (companyId: string) =>
|
||||
`/companies/${companyId}/change-requests`,
|
||||
CHANGE_REQUEST_APPROVE: (id: string) =>
|
||||
`/companies/change-requests/${id}/approve`,
|
||||
CHANGE_REQUEST_REJECT: (id: string) =>
|
||||
`/companies/change-requests/${id}/reject`,
|
||||
BOOKINGS_CUSTOMER_VIEW: (id: string) =>
|
||||
`/bookings/by-company/${id}/customer-view`,
|
||||
PAYMENTS_CUSTOMER_VIEW: (id: string) =>
|
||||
@@ -326,6 +332,11 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
|
||||
BOOKING_LOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
|
||||
BOOKING_UNLOAD: (id: string, bookingId: string) =>
|
||||
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
|
||||
INTERCITY_CANDIDATES: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity-candidates`,
|
||||
INTERCITY_ACCEPT: (id: string) =>
|
||||
|
||||
@@ -15,11 +15,56 @@ import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
// The two carousel window lists share the MyBookingWindow-shaped row and can be
|
||||
// patched in place. The batch board is a richer, differently-shaped view, so it
|
||||
// stays on a (debounced) invalidate.
|
||||
const WINDOW_ACTIONS = new Set(["all-booking-windows", "contractBookingWindows"]);
|
||||
|
||||
/** Shape shared by both carousel window lists (all-lanes + contract-scoped). */
|
||||
interface WindowRow {
|
||||
scheduleId: string;
|
||||
windowPhase: string | null;
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
}
|
||||
|
||||
function isWindowKey(key: readonly unknown[]): boolean {
|
||||
return key[0] === "train-scheduling" && WINDOW_ACTIONS.has(String(key[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes for staff. Every phase transition
|
||||
* the window engine applies invalidates the GL windows carousel and the batch
|
||||
* board, so both flip the moment the backend does — polling stays only as a
|
||||
* fallback.
|
||||
* Fold a server phase push onto a cached window row, recomputing isOpenNow the
|
||||
* same way the server does (phase OPEN + status OPEN) so live-patched state can
|
||||
* never disagree with a fresh REST fetch on refresh.
|
||||
*/
|
||||
function applyEvent<T extends WindowRow>(row: T, event: BookingWindowPhaseEvent): T {
|
||||
return {
|
||||
...row,
|
||||
windowPhase: event.phase,
|
||||
bookingWindowStatus: event.bookingWindowStatus ?? row.bookingWindowStatus,
|
||||
bookingCycleNo: event.bookingCycleNo,
|
||||
isOpenNow: event.phase === "OPEN" && event.bookingWindowStatus === "OPEN",
|
||||
windowOpensAt: event.windowOpensAt,
|
||||
windowClosesAt: event.windowClosesAt,
|
||||
docReviewEndsAt: event.docReviewEndsAt,
|
||||
paymentPhaseEndsAt: event.paymentPhaseEndsAt,
|
||||
departureDate: event.scheduledDepartureDate ?? row.departureDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to live booking-window pushes for staff. A phase transition carries
|
||||
* the schedule's full new state; we fold it straight into the carousel window
|
||||
* lists with setQueriesData rather than invalidating — same rationale as the
|
||||
* portal hook (no per-push refetch storm; live + refreshed state agree, killing
|
||||
* the refresh-jump). The batch board is a different-shaped view, so it keeps a
|
||||
* debounced invalidate, as do pushes for schedules not present in any list.
|
||||
*/
|
||||
export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
const qc = useQueryClient();
|
||||
@@ -47,19 +92,60 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
console.debug("[booking-windows] socket disconnected:", reason),
|
||||
);
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(_event: BookingWindowPhaseEvent) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "all-booking-windows"],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
// Coalesce the batch-board refresh (and the unknown-schedule fallback) so a
|
||||
// burst of pushes triggers at most one invalidation per window.
|
||||
let refetchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const scheduleRefetch = (includeWindowLists: boolean) => {
|
||||
if (refetchTimer) return;
|
||||
refetchTimer = setTimeout(() => {
|
||||
refetchTimer = null;
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
});
|
||||
if (includeWindowLists) {
|
||||
void qc.invalidateQueries({
|
||||
predicate: (q) => isWindowKey(q.queryKey),
|
||||
});
|
||||
}
|
||||
}, 800);
|
||||
};
|
||||
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.PHASE,
|
||||
(event: BookingWindowPhaseEvent) => {
|
||||
let patchedSomewhere = false;
|
||||
|
||||
qc.setQueriesData<WindowRow[]>(
|
||||
{ predicate: (q) => isWindowKey(q.queryKey) },
|
||||
(rows) => {
|
||||
if (!rows) return rows;
|
||||
let changed = false;
|
||||
const next = rows.map((row) => {
|
||||
if (row.scheduleId !== event.scheduleId) return row;
|
||||
changed = true;
|
||||
patchedSomewhere = true;
|
||||
return applyEvent(row, event);
|
||||
});
|
||||
return changed ? next : rows;
|
||||
},
|
||||
);
|
||||
|
||||
// Refresh the batch-board DETAIL for the schedule that transitioned so the
|
||||
// Priority Tracking tab reranks + updates its countdowns immediately (the
|
||||
// detail is a different shape from the list — invalidate, don't patch).
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
|
||||
});
|
||||
|
||||
// Always refresh the batch board (different shape, not patched). When the
|
||||
// schedule wasn't in any window list either, refresh those too so a newly
|
||||
// announced window surfaces. Both debounced — no per-push stampede.
|
||||
scheduleRefetch(!patchedSomewhere);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (refetchTimer) clearTimeout(refetchTimer);
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
label: "In Transit",
|
||||
color: "bg-sky-50 text-sky-700 border-sky-200",
|
||||
},
|
||||
ARRIVED: {
|
||||
label: "Arrived",
|
||||
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Completed",
|
||||
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||
@@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
|
||||
color: "text-sky-600",
|
||||
stage: 4,
|
||||
},
|
||||
ARRIVED: {
|
||||
title: "Arrived",
|
||||
description: "Cargo unloaded at its destination yard.",
|
||||
color: "text-emerald-600",
|
||||
stage: 4,
|
||||
},
|
||||
COMPLETED: {
|
||||
title: "Completed",
|
||||
description: "Booking fulfilled.",
|
||||
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
|
||||
},
|
||||
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
|
||||
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
|
||||
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
|
||||
},
|
||||
{
|
||||
label: "Operations",
|
||||
statuses: ["PAID", "IN_TRANSIT"],
|
||||
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
|
||||
},
|
||||
{ label: "Done", statuses: ["COMPLETED"] },
|
||||
] as const;
|
||||
|
||||
@@ -39,7 +39,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
|
||||
booking.serviceType?.label ??
|
||||
booking.serviceType?.name ??
|
||||
booking.serviceType?.code,
|
||||
serviceTypeBonus: booking.serviceType?.priorityBonusPoints ?? 0,
|
||||
trainScheduleId: booking.trainScheduleId ?? null,
|
||||
isGovernment: booking.isGovernment ?? false,
|
||||
governmentInstitution: booking.governmentInstitution ?? null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
@@ -142,14 +144,17 @@ export default function BookingRequestDetailPage() {
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
const showTabs = showClearanceTab || isGeneralContract;
|
||||
// The Documents tab is always available — every booking can accrue clearance,
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: "overview";
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -187,10 +192,10 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused */}
|
||||
{/* LEFT — primary content, split into tabs to keep each view focused.
|
||||
The Documents tab is always present, so the tab bar always renders. */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{showTabs ? (
|
||||
<Tabs
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="pills"
|
||||
@@ -217,6 +222,12 @@ export default function BookingRequestDetailPage() {
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
@@ -238,10 +249,10 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action / summary rail */}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{/* Windows on this contract's routes/direction only — tells GL ET when
|
||||
it can actually create the booking without checking the schedule board. */}
|
||||
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
|
||||
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -32,6 +33,8 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
BookingStatusBadge,
|
||||
ChangeRequestPendingBadge,
|
||||
ChangeRequestReview,
|
||||
CompanyStatusBadge,
|
||||
CompanyTypeBadge,
|
||||
InvoiceStatusBadge,
|
||||
@@ -161,25 +164,85 @@ export default function CustomerDetailPage() {
|
||||
{
|
||||
id: "type",
|
||||
header: "Role",
|
||||
cell: ({ row }) => <ProfileTypeBadge type={row.original.type} />,
|
||||
},
|
||||
{
|
||||
id: "reference",
|
||||
header: "Reference",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
<div className="space-y-2">
|
||||
<ProfileTypeBadge type={row.original.type} />
|
||||
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "businessLicense",
|
||||
header: "Business license",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.businessLicense || "—"}
|
||||
</Text>
|
||||
),
|
||||
id: "licenseFiles",
|
||||
header: "License documents",
|
||||
cell: ({ row }) => {
|
||||
const files = row.original.licenseFiles ?? [];
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
{files.map((f) => (
|
||||
<Group key={f.id} gap={6} wrap="nowrap">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`View ${f.name}`}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye size={14} />
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
lineClamp={1}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
style={{
|
||||
maxWidth: 170,
|
||||
textAlign: "left",
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removing
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
@@ -207,7 +270,7 @@ export default function CustomerDetailPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[view],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -501,13 +564,13 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -534,6 +597,8 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
@@ -614,7 +679,7 @@ export default function CustomerDetailPage() {
|
||||
<ProfileChips profiles={company.companyProfiles} />
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={860}>
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={profileColumns}
|
||||
data={company.companyProfiles}
|
||||
@@ -641,9 +706,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -663,9 +728,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -679,12 +744,12 @@ export default function CustomerDetailPage() {
|
||||
</Text>
|
||||
<Stack gap="md">
|
||||
{licenseProfiles.map((p) => (
|
||||
<Stack key={p.id} gap={4}>
|
||||
<Stack key={p.id} gap={6}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{humanize(p.type)} · {p.reference}
|
||||
</Text>
|
||||
{(p.licenseFiles ?? []).map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Group key={f.id} gap={8} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
component="button"
|
||||
@@ -692,16 +757,37 @@ export default function CustomerDetailPage() {
|
||||
onClick={() =>
|
||||
view({
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
url: fileViewUrl(f.id),
|
||||
mimeType: f.mimeType,
|
||||
})
|
||||
}
|
||||
size="xs"
|
||||
style={{
|
||||
textDecoration:
|
||||
f.status === "pending_remove"
|
||||
? "line-through"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
{f.status === "pending_add" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
{f.status === "pending_remove" && (
|
||||
<Badge size="xs" color="red" variant="light">
|
||||
Removal pending
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
{(p.licenseFiles ?? []).length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No license documents.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -723,9 +809,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -746,9 +832,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
invoicesQuery.isError
|
||||
? {
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load invoices.",
|
||||
onRetry: () => void invoicesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pagination={{
|
||||
|
||||
@@ -18,14 +18,11 @@ import {
|
||||
HardDrive,
|
||||
Layers,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
|
||||
headerClassName,
|
||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||
},
|
||||
// Settings are seeded/fixed — staff may only update a setting's fields,
|
||||
// not create, edit, or delete the settings themselves.
|
||||
cell: ({ row }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
|
||||
Fields
|
||||
</Button>
|
||||
</ManageFileUploadFieldsDialog>
|
||||
|
||||
<EditFileUploadSettingDialog mode="edit" setting={setting}>
|
||||
<ActionIcon variant="default" aria-label="Edit setting">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</EditFileUploadSettingDialog>
|
||||
|
||||
<DeleteFileUploadSettingDialog
|
||||
settingLabel={setting.label}
|
||||
settingCode={setting.code}
|
||||
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
color="red"
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label="Delete setting"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</DeleteFileUploadSettingDialog>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [deleteMutation]);
|
||||
}, []);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
|
||||
<PageHeader
|
||||
title="File upload settings"
|
||||
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
||||
action={
|
||||
<EditFileUploadSettingDialog mode="create">
|
||||
<Button leftSection={<Plus size={18} />}>New setting</Button>
|
||||
</EditFileUploadSettingDialog>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStrip
|
||||
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
|
||||
emptyMessage={
|
||||
query.trim()
|
||||
? "No file upload settings match your search."
|
||||
: 'No file upload settings yet. Click "New setting" to add one.'
|
||||
: "No file upload settings configured."
|
||||
}
|
||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
|
||||
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
record={historyTarget}
|
||||
/>
|
||||
) : (
|
||||
<FleetHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
entity={slug === "vehicles" ? "vehicle" : "driver"}
|
||||
record={historyTarget}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -286,6 +286,9 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so truck-leaving prefills the
|
||||
// details captured at arrival (plate, driver, tare, gate-in).
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
@@ -1282,6 +1285,17 @@ const LastMilePage = () => {
|
||||
Boolean(releaseRow?.releaseOrderReference) && !releaseRow?.releaseDate && !pastTransit;
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
{pastTransit && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<Receipt size={13} />}
|
||||
onClick={() => setDetentionRecord(row.original)}
|
||||
>
|
||||
Detention
|
||||
</Button>
|
||||
)}
|
||||
<Menu
|
||||
position="bottom-end"
|
||||
width={200}
|
||||
|
||||
@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
|
||||
{ label: "Both", value: "BOTH" },
|
||||
];
|
||||
|
||||
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
|
||||
const YARD_COUNTRIES = [
|
||||
{ label: "Ethiopia", value: "Ethiopia" },
|
||||
{ label: "Djibouti", value: "Djibouti" },
|
||||
];
|
||||
|
||||
const APPROVAL_ROLES = [
|
||||
{ label: "Line staff", value: "LINE_STAFF" },
|
||||
{ label: "Director", value: "DIRECTOR" },
|
||||
@@ -181,6 +187,7 @@ const CURRENCIES = [
|
||||
const PRIORITY_CONFIG_TYPES = [
|
||||
{ label: "Wagon count", value: "WAGON" },
|
||||
{ label: "Payment currency", value: "CURRENCY" },
|
||||
{ label: "Customs clearance", value: "CUSTOMS" },
|
||||
];
|
||||
|
||||
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
|
||||
@@ -304,7 +311,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
slug: "priority-configs",
|
||||
label: "Priority Rules",
|
||||
category: "rules",
|
||||
subtitle: "Wagon-count and payment-currency scoring rules",
|
||||
subtitle: "Wagon-count, payment-currency, and customs scoring rules",
|
||||
searchPlaceholder: "Search priority rules...",
|
||||
orderConfig: { field: "displayOrder", label: "Display order" },
|
||||
columns: [
|
||||
@@ -331,7 +338,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...CURRENCIES],
|
||||
placeholder: "Select a currency",
|
||||
hideWhen: { field: "type", equals: ["WAGON"] },
|
||||
hideWhen: { field: "type", equals: ["WAGON", "CUSTOMS"] },
|
||||
},
|
||||
{ name: "minWagonCount", label: "Min wagon count", type: "number", required: true },
|
||||
{ name: "maxWagonCount", label: "Max wagon count", type: "number", required: true },
|
||||
@@ -351,7 +358,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
codeColumn("code"),
|
||||
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
|
||||
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
|
||||
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
|
||||
activeColumn,
|
||||
],
|
||||
formFields: [
|
||||
@@ -361,7 +367,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
|
||||
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
|
||||
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
|
||||
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
@@ -431,7 +436,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "country", label: "Country", type: "text", required: true },
|
||||
{
|
||||
name: "country",
|
||||
label: "Country",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: YARD_COUNTRIES,
|
||||
},
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
TrainFront,
|
||||
Trophy,
|
||||
Weight,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
@@ -55,6 +56,8 @@ import {
|
||||
WindowStatusPill,
|
||||
} from "@/components/trainScheduling/batchVisuals";
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { PriorityTrackingTab } from "@/components/trainScheduling/PriorityTrackingTab";
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import { BookingsManager } from "./BookingsManager";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -578,9 +581,23 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.batchBoardDetail.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 30_000,
|
||||
// Poll fast while a window cycle is actively moving (open / doc-review /
|
||||
// payment) so the priority ranking + pay countdowns stay live; back off to
|
||||
// 30s once the cycle is idle (pre-window / closed / done).
|
||||
refetchInterval: (query) => {
|
||||
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
|
||||
?.windowPhase;
|
||||
return phase === "OPEN" ||
|
||||
phase === "DOC_REVIEW" ||
|
||||
phase === "PAYMENT"
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Keep the board in sync with server-pushed window-phase transitions too
|
||||
// (invalidates the batch-board list + patches window carousels).
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const runAllocation = useMutation(
|
||||
api.trainScheduling.runAllocation.mutationOptions(),
|
||||
);
|
||||
@@ -735,6 +752,13 @@ export default function BatchScheduleDetailPage() {
|
||||
<Tabs value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview">Overview</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="priority"
|
||||
leftSection={<Trophy size={14} />}
|
||||
>
|
||||
Priority Tracking{" "}
|
||||
{allBookings.length > 0 && `(${allBookings.length})`}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="composition">
|
||||
Train Composition{" "}
|
||||
{scheduleDetailQuery.data?.trainSet?.wagons &&
|
||||
@@ -1095,6 +1119,10 @@ export default function BatchScheduleDetailPage() {
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="priority" pt="lg">
|
||||
<PriorityTrackingTab data={data} bookings={allBookings} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="composition" pt="lg">
|
||||
{scheduleDetailQuery.data && scheduleDetailQuery.data.trainSet ? (
|
||||
<Group align="stretch" gap="md" wrap="nowrap">
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
@@ -26,7 +26,11 @@ import {
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
|
||||
import { RouteCorridor, StatusPill } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
scheduleBrand,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
@@ -52,8 +56,11 @@ function formatDateTime(iso?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Compact icon + label + value cell used in the header meta strip. */
|
||||
function MetaStat({
|
||||
/**
|
||||
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
|
||||
* value, laid on the translucent panel over the gradient.
|
||||
*/
|
||||
function HeroStat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
@@ -63,15 +70,33 @@ function MetaStat({
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={32} radius="md" variant="light" color="edr-green">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="10px" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.5 }}>
|
||||
</Box>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={700} c="dark.5" truncate>
|
||||
<Text size="sm" fw={700} c="white" truncate>
|
||||
{value}
|
||||
</Text>
|
||||
</Stack>
|
||||
@@ -79,6 +104,38 @@ function MetaStat({
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
|
||||
function SectionHead({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} size="sm">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{hint}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_STYLE = {
|
||||
borderColor: scheduleBrand.mutedBorder,
|
||||
boxShadow: scheduleBrand.shadowSm,
|
||||
} as const;
|
||||
|
||||
export default function TrainScheduleTrackPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const { toast } = useToast();
|
||||
@@ -116,9 +173,13 @@ export default function TrainScheduleTrackPage() {
|
||||
const canLog = track.status === "DISPATCHED";
|
||||
const totalStations = track.stations.length;
|
||||
const reached = Math.min(track.currentSequenceNo + 1, totalStations);
|
||||
const progressPct = totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
|
||||
const progressPct =
|
||||
totalStations > 1 ? (track.currentSequenceNo / (totalStations - 1)) * 100 : 0;
|
||||
const clampedPct = Math.min(100, Math.max(0, progressPct));
|
||||
const currentStation = track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
|
||||
const currentStation =
|
||||
track.stations[Math.max(0, track.currentSequenceNo)]?.label ?? "—";
|
||||
const inTransit = track.status === "DISPATCHED";
|
||||
const arrived = track.status === "ARRIVED";
|
||||
|
||||
const handleLog = (sequenceNo: number) => {
|
||||
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
|
||||
@@ -156,160 +217,259 @@ export default function TrainScheduleTrackPage() {
|
||||
Back to schedule
|
||||
</Button>
|
||||
|
||||
{/* Header */}
|
||||
<Paper radius="lg" withBorder p="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: freightBrand.gradient,
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={24} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800}>
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={360}>
|
||||
<RouteCorridor origin={track.origin} destination={track.destination} variant="compact" />
|
||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||
<Paper
|
||||
radius="lg"
|
||||
p={0}
|
||||
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
background: scheduleBrand.heroGradient,
|
||||
padding: "26px 28px",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* soft decorative glow, purely artistic */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -80,
|
||||
right: -60,
|
||||
width: 260,
|
||||
height: 260,
|
||||
borderRadius: "50%",
|
||||
background: "rgba(255,255,255,0.10)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="xl"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* left — identity + route */}
|
||||
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
|
||||
<Group gap="md" align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.26)",
|
||||
color: "white",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Navigation size={26} />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
<StatusPill status={track.status} />
|
||||
</Group>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
<Title order={3} fw={800} c="white">
|
||||
Train tracking
|
||||
</Title>
|
||||
{track.trainNumber ? (
|
||||
<Badge
|
||||
variant="white"
|
||||
color="dark"
|
||||
radius="sm"
|
||||
styles={{ root: { color: freightBrand.primaryDark } }}
|
||||
>
|
||||
{track.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{track.direction ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: {
|
||||
color: "white",
|
||||
borderColor: "rgba(255,255,255,0.5)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{track.direction}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Box maw={380}>
|
||||
<RouteCorridor
|
||||
origin={track.origin}
|
||||
destination={track.destination}
|
||||
variant="compact"
|
||||
onDark
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{/* Journey progress */}
|
||||
<Box>
|
||||
<Group justify="space-between" mb={6}>
|
||||
<Text size="xs" fw={700} c="gray.7" tt="uppercase" style={{ letterSpacing: 0.4 }}>
|
||||
Journey progress
|
||||
</Text>
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
{reached} / {totalStations} stations · {Math.round(clampedPct)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={clampedPct}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
color="edr-green"
|
||||
striped={track.status === "DISPATCHED"}
|
||||
animated={track.status === "DISPATCHED"}
|
||||
/>
|
||||
</Box>
|
||||
<Group gap="sm">
|
||||
<StatusPill status={track.status} size="md" />
|
||||
<Box
|
||||
px={12}
|
||||
py={5}
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
border: "1px solid rgba(255,255,255,0.24)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
|
||||
{arrived
|
||||
? "Journey complete"
|
||||
: inTransit
|
||||
? `En route · ${currentStation}`
|
||||
: "Awaiting dispatch"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Meta strip */}
|
||||
<Group justify="space-between" wrap="wrap" gap="lg">
|
||||
<MetaStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<MetaStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<MetaStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
{/* right — progress ring, the artistic focal point */}
|
||||
<RingProgress
|
||||
size={132}
|
||||
thickness={11}
|
||||
roundCaps
|
||||
sections={[{ value: clampedPct, color: "white" }]}
|
||||
rootColor="rgba(255,255,255,0.22)"
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1} c="white">
|
||||
{Math.round(clampedPct)}%
|
||||
</Text>
|
||||
<Text
|
||||
size="10px"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
|
||||
>
|
||||
{reached}/{totalStations} stops
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* glass meta strip below the wash */}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="wrap"
|
||||
gap="lg"
|
||||
px={28}
|
||||
py="md"
|
||||
style={{
|
||||
background: freightBrand.primaryDark,
|
||||
borderTop: "1px solid rgba(255,255,255,0.12)",
|
||||
}}
|
||||
>
|
||||
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
|
||||
<HeroStat
|
||||
icon={<CalendarClock size={16} />}
|
||||
label="Departed"
|
||||
value={formatDateTime(track.actualDepartureAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Flag size={16} />}
|
||||
label="Arrived"
|
||||
value={formatDateTime(track.actualArrivalAt)}
|
||||
/>
|
||||
<HeroStat
|
||||
icon={<Train size={16} />}
|
||||
label="Stations"
|
||||
value={`${reached} of ${totalStations}`}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{/* Corridor */}
|
||||
<Paper radius="lg" p="lg" withBorder>
|
||||
{/* ── Route corridor ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Stack gap="md">
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
|
||||
<Navigation size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Route corridor
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: track.status === "ARRIVED"
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SectionHead
|
||||
icon={<Navigation size={17} />}
|
||||
title="Route corridor"
|
||||
hint={
|
||||
canLog
|
||||
? "Log the train passing each station; the final station marks arrival."
|
||||
: arrived
|
||||
? "This train has arrived at its destination."
|
||||
: "Tracking becomes available once the train is dispatched."
|
||||
}
|
||||
/>
|
||||
<RouteCorridorTrack
|
||||
stations={track.stations}
|
||||
currentSequenceNo={track.currentSequenceNo}
|
||||
checkpoints={track.checkpoints}
|
||||
canLog={canLog}
|
||||
loggingSeq={
|
||||
recordCheckpoint.isPending ? recordCheckpoint.variables?.payload.sequenceNo : null
|
||||
recordCheckpoint.isPending
|
||||
? recordCheckpoint.variables?.payload.sequenceNo
|
||||
: null
|
||||
}
|
||||
onLogCheckpoint={handleLog}
|
||||
/>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Checkpoint log */}
|
||||
<Paper radius="lg" p="lg" withBorder>
|
||||
<Group gap="sm" align="center" wrap="nowrap" mb="md">
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="edr-green">
|
||||
<CheckCircle2 size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0}>
|
||||
<Text fw={800} size="sm">
|
||||
Checkpoint log
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.checkpoints.length} event{track.checkpoints.length === 1 ? "" : "s"} recorded
|
||||
</Text>
|
||||
</Stack>
|
||||
{/* ── Checkpoint log ── */}
|
||||
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<SectionHead
|
||||
icon={<CheckCircle2 size={17} />}
|
||||
title="Checkpoint log"
|
||||
hint={`${track.checkpoints.length} event${
|
||||
track.checkpoints.length === 1 ? "" : "s"
|
||||
} recorded`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{track.checkpoints.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<ThemeIcon size={44} radius="xl" variant="light" color="gray">
|
||||
<MapPin size={20} />
|
||||
<Stack
|
||||
align="center"
|
||||
gap="xs"
|
||||
py={40}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: `1px dashed ${scheduleBrand.mutedBorder}`,
|
||||
background: scheduleBrand.softSurface,
|
||||
}}
|
||||
>
|
||||
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
|
||||
<MapPin size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="gray.7">
|
||||
<Text size="sm" fw={700} c="gray.7">
|
||||
No checkpoints yet
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" maw={300}>
|
||||
Each station the train passes will be logged here with its timestamp.
|
||||
<Text size="xs" c="dimmed" ta="center" maw={320}>
|
||||
Each station the train passes will be logged here with its
|
||||
timestamp.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Timeline active={track.checkpoints.length} bulletSize={22} lineWidth={2} color="edr-green">
|
||||
<Timeline
|
||||
active={track.checkpoints.length}
|
||||
bulletSize={24}
|
||||
lineWidth={2}
|
||||
color="edr-green"
|
||||
>
|
||||
{track.checkpoints.map((cp) => (
|
||||
<Timeline.Item
|
||||
key={cp.id}
|
||||
bullet={cp.kind === "ARRIVED" ? <CheckCircle2 size={13} /> : <MapPin size={12} />}
|
||||
bullet={
|
||||
cp.kind === "ARRIVED" ? (
|
||||
<CheckCircle2 size={13} />
|
||||
) : (
|
||||
<MapPin size={12} />
|
||||
)
|
||||
}
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="sm">
|
||||
@@ -319,7 +479,13 @@ export default function TrainScheduleTrackPage() {
|
||||
size="xs"
|
||||
radius="sm"
|
||||
variant="light"
|
||||
color={cp.kind === "ARRIVED" ? "teal" : cp.kind === "DEPARTED" ? "blue" : "edr-green"}
|
||||
color={
|
||||
cp.kind === "ARRIVED"
|
||||
? "teal"
|
||||
: cp.kind === "DEPARTED"
|
||||
? "blue"
|
||||
: "edr-green"
|
||||
}
|
||||
>
|
||||
{cp.kind}
|
||||
</Badge>
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
|
||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -863,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</ThemeIcon>
|
||||
<Stack gap={6}>
|
||||
<Group gap="sm" align="center" wrap="wrap">
|
||||
{schedule.reference ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||
>
|
||||
{schedule.reference}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
@@ -1131,6 +1142,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
|
||||
{scheduleId ? (
|
||||
<IntercityRideAlongPanel
|
||||
scheduleId={scheduleId}
|
||||
|
||||
@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [freightFilter, setFreightFilter] = useState("ALL");
|
||||
const [originFilter, setOriginFilter] = useState("ALL");
|
||||
const [destinationFilter, setDestinationFilter] = useState("ALL");
|
||||
// Default: newest-created first, matching the API's default order.
|
||||
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
|
||||
"createdAt",
|
||||
);
|
||||
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] =
|
||||
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
|
||||
return base;
|
||||
}, [allSchedules]);
|
||||
|
||||
// Distinct origins/destinations present in the loaded schedules, for the
|
||||
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
|
||||
const originOptions = useMemo(
|
||||
() =>
|
||||
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
|
||||
[allSchedules],
|
||||
);
|
||||
const destinationOptions = useMemo(
|
||||
() =>
|
||||
[
|
||||
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
|
||||
].sort() as string[],
|
||||
[allSchedules],
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return allSchedules.filter((s) => {
|
||||
const matched = allSchedules.filter((s) => {
|
||||
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
||||
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
||||
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
|
||||
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
|
||||
return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.reference,
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [allSchedules, search, statusFilter, freightFilter]);
|
||||
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
const sorted = [...matched].sort((a, b) => {
|
||||
let cmp = 0;
|
||||
if (sortBy === "reference") {
|
||||
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
|
||||
} else {
|
||||
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
|
||||
const av = new Date(a[sortBy] ?? 0).getTime();
|
||||
const bv = new Date(b[sortBy] ?? 0).getTime();
|
||||
cmp = av - bv;
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
return sorted;
|
||||
}, [
|
||||
allSchedules,
|
||||
search,
|
||||
statusFilter,
|
||||
freightFilter,
|
||||
originFilter,
|
||||
destinationFilter,
|
||||
sortBy,
|
||||
sortDir,
|
||||
]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{
|
||||
id: "reference",
|
||||
header: "Ref",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
|
||||
{row.original.reference ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "date",
|
||||
header: "Departure",
|
||||
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Origin"
|
||||
searchable
|
||||
value={originFilter}
|
||||
onChange={(v) => setOriginFilter(v ?? "ALL")}
|
||||
data={[
|
||||
{ value: "ALL", label: "All origins" },
|
||||
...originOptions.map((o) => ({ value: o, label: o })),
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Destination"
|
||||
searchable
|
||||
value={destinationFilter}
|
||||
onChange={(v) => setDestinationFilter(v ?? "ALL")}
|
||||
data={[
|
||||
{ value: "ALL", label: "All destinations" },
|
||||
...destinationOptions.map((d) => ({ value: d, label: d })),
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={`${sortBy}:${sortDir}`}
|
||||
onChange={(v) => {
|
||||
if (!v) return;
|
||||
const [by, dir] = v.split(":") as [
|
||||
typeof sortBy,
|
||||
typeof sortDir,
|
||||
];
|
||||
setSortBy(by);
|
||||
setSortDir(dir);
|
||||
}}
|
||||
data={[
|
||||
{ value: "createdAt:desc", label: "Newest created" },
|
||||
{ value: "createdAt:asc", label: "Oldest created" },
|
||||
{ value: "scheduleDate:desc", label: "Departure ↓" },
|
||||
{ value: "scheduleDate:asc", label: "Departure ↑" },
|
||||
{ value: "reference:asc", label: "Reference ↑" },
|
||||
{ value: "reference:desc", label: "Reference ↓" },
|
||||
]}
|
||||
w={170}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -693,9 +805,16 @@ function ScheduleCard({
|
||||
<Train size={18} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{schedule.reference ? (
|
||||
<Text size="xs" fw={700} ff="monospace" c="edr-green.8">
|
||||
{schedule.reference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fw={600} size="sm" lineClamp={1}>
|
||||
{schedule.routeName ?? "Train schedule"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{day} · {time}
|
||||
</Text>
|
||||
|
||||
@@ -38,7 +38,7 @@ const columns: ColumnDef<Loading>[] = [
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
header: 'Loaded Weight (kg)',
|
||||
header: 'Loaded Weight (t)',
|
||||
cell: ({ row }) => formatNumber(row.original.loadedWeight),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -203,7 +203,7 @@ function PendingPaymentTable({ items, onNavigate }: PendingPaymentTableProps) {
|
||||
},
|
||||
{ id: 'warehouse', header: 'Warehouse', cell: ({ row }) => row.original.warehouse?.code ?? '—' },
|
||||
{ id: 'zone', header: 'Zone', cell: ({ row }) => row.original.zone?.code ?? '—' },
|
||||
{ id: 'weight', header: 'Weight (kg)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{ id: 'weight', header: 'Weight (t)', cell: ({ row }) => formatNumber(row.original.weight) },
|
||||
{
|
||||
id: 'payment',
|
||||
header: 'Payment',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { Info, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
useDeleteAllocationRule,
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
useUpdateAllocationRule,
|
||||
useUpdateFeeRule,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import {
|
||||
@@ -37,6 +39,9 @@ import {
|
||||
FEE_RULE_BASIS_LABELS,
|
||||
FEE_RULE_TYPES,
|
||||
FEE_RULE_TYPE_LABELS,
|
||||
VEHICLE_TYPES,
|
||||
type AllocationRule,
|
||||
type FeeRule,
|
||||
type FeeRuleBasis,
|
||||
type FeeRuleType,
|
||||
} from '@/types/warehouse';
|
||||
@@ -120,8 +125,10 @@ function AllocationRules() {
|
||||
const { data, isLoading } = useAllocationRules();
|
||||
const { data: yards = [], isLoading: yardsLoading } = useAllWarehouseYards();
|
||||
const create = useCreateAllocationRule();
|
||||
const update = useUpdateAllocationRule();
|
||||
const remove = useDeleteAllocationRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -141,7 +148,8 @@ function AllocationRules() {
|
||||
label: `${yard.code} - ${yard.name}${yard.warehouse?.code ? ` (${yard.warehouse.code})` : ''}`,
|
||||
}));
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
priority: 100,
|
||||
@@ -152,6 +160,22 @@ function AllocationRules() {
|
||||
targetYardCode: '',
|
||||
storageType: '',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: AllocationRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
priority: rule.priority ?? 100,
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerStatus: rule.containerStatus ?? '',
|
||||
targetYardCode: rule.targetYardCode ?? '',
|
||||
storageType: rule.storageType ?? '',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim() || !form.targetYardCode.trim()) {
|
||||
@@ -159,7 +183,7 @@ function AllocationRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
priority: form.priority,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
@@ -169,10 +193,20 @@ function AllocationRules() {
|
||||
targetYardCode: form.targetYardCode.trim(),
|
||||
storageType: clean(form.storageType) ?? null,
|
||||
isActive: true,
|
||||
} as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
};
|
||||
try {
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Allocation rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Allocation rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -181,7 +215,7 @@ function AllocationRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) matched by ascending priority
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New allocation rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -228,14 +262,19 @@ function AllocationRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -244,7 +283,7 @@ function AllocationRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New allocation rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit allocation rule' : 'New allocation rule'} centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" padding="sm" bg="gray.0">
|
||||
<Stack gap={4}>
|
||||
@@ -339,11 +378,11 @@ function AllocationRules() {
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -362,8 +401,10 @@ function FeeRules() {
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const update = useUpdateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE' as FeeRuleType,
|
||||
@@ -372,6 +413,7 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
@@ -389,8 +431,11 @@ function FeeRules() {
|
||||
// Truck detention: per truck per day after an HOURS-based grace (default 3h),
|
||||
// with day tiers. Uses "free hours" instead of "free days".
|
||||
const isTruckDetention = form.ruleType === 'TRUCK_DETENTION_FEE';
|
||||
// Double handling + truck detention apply to IMPORT only — trade direction is locked.
|
||||
const isImportOnly = isDoubleHandling || isTruckDetention;
|
||||
|
||||
const resetForm = () =>
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
@@ -399,12 +444,34 @@ function FeeRules() {
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
vehicleType: '',
|
||||
freeDays: 3,
|
||||
freeHours: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
};
|
||||
|
||||
const startEdit = (rule: FeeRule) => {
|
||||
setForm({
|
||||
name: rule.name,
|
||||
ruleType: rule.ruleType,
|
||||
basis: (rule.basis as FeeRuleBasis) ?? 'PER_CONTAINER',
|
||||
freightType: rule.freightType ?? '',
|
||||
tradeDirection: rule.tradeDirection ?? '',
|
||||
cargoTypeCode: rule.cargoTypeCode ?? '',
|
||||
containerType: rule.containerType ?? '',
|
||||
vehicleType: rule.vehicleType ?? '',
|
||||
freeDays: rule.freeDays ?? 3,
|
||||
freeHours: rule.freeHours ?? 3,
|
||||
ratePerDay: rule.ratePerDay ?? 0,
|
||||
tiers: (rule.tiers ?? []).map((t) => ({ fromDay: t.fromDay, toDay: t.toDay, ratePerDay: t.ratePerDay })),
|
||||
currency: rule.currency ?? 'USD',
|
||||
});
|
||||
setEditingId(rule.id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
@@ -461,7 +528,7 @@ function FeeRules() {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
tradeDirection: isImportOnly ? 'IMPORT' : clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
// Double handling: flat basis × rate. Truck detention: HOURS-based grace.
|
||||
@@ -469,17 +536,22 @@ function FeeRules() {
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
...(isDoubleHandling ? { basis: form.basis } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours } : {}),
|
||||
...(isTruckDetention ? { freeHours: form.freeHours, vehicleType: clean(form.vehicleType) ?? null } : {}),
|
||||
...(!isDoubleHandling && tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
if (editingId) {
|
||||
await update.mutateAsync({ id: editingId, payload: payload as never });
|
||||
toast({ title: 'Fee rule updated' });
|
||||
} else {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
if (!editingId && tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
@@ -500,7 +572,7 @@ function FeeRules() {
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: editingId ? 'Update failed' : 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -510,7 +582,7 @@ function FeeRules() {
|
||||
<Text c="dimmed" size="sm">
|
||||
{rules.length} rule(s) - most specific match applies
|
||||
</Text>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => setOpen(true)}>
|
||||
<Button leftSection={<Plus size={16} />} onClick={() => { resetForm(); setOpen(true); }}>
|
||||
New fee rule
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -572,14 +644,19 @@ function FeeRules() {
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="blue" onClick={() => startEdit(rule)} title="Edit">
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => remove.mutate(rule.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -588,7 +665,7 @@ function FeeRules() {
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title="New fee rule" centered size="lg">
|
||||
<Modal opened={open} onClose={() => { setOpen(false); resetForm(); }} title={editingId ? 'Edit fee rule' : 'New fee rule'} centered size="lg">
|
||||
<Stack gap="sm">
|
||||
<Group grow>
|
||||
<TextInput
|
||||
@@ -633,11 +710,23 @@ function FeeRules() {
|
||||
/>
|
||||
<Select
|
||||
label="Trade direction"
|
||||
description={isImportOnly ? 'Import only for this fee type' : undefined}
|
||||
data={TRADE}
|
||||
value={form.tradeDirection || null}
|
||||
value={isImportOnly ? 'IMPORT' : form.tradeDirection || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
disabled={isImportOnly}
|
||||
clearable={!isImportOnly}
|
||||
/>
|
||||
{isTruckDetention && (
|
||||
<Select
|
||||
label="Truck type"
|
||||
placeholder="Any truck type"
|
||||
data={VEHICLE_TYPES.map((v) => ({ value: v, label: v.charAt(0) + v.slice(1).toLowerCase() }))}
|
||||
value={form.vehicleType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, vehicleType: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
@@ -758,11 +847,11 @@ function FeeRules() {
|
||||
</Stack>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
<Button variant="default" onClick={() => { setOpen(false); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button loading={create.isPending} onClick={submit}>
|
||||
Create
|
||||
<Button loading={create.isPending || update.isPending} onClick={submit}>
|
||||
{editingId ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyStats,
|
||||
@@ -99,6 +100,7 @@ import type {
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
PayInvoicePayload,
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
@@ -180,6 +182,7 @@ import {
|
||||
wagonService,
|
||||
type Wagon,
|
||||
type WagonListFilters,
|
||||
type WagonMovementRecord,
|
||||
} from "./wagon.service";
|
||||
import { warehouseService } from "./warehouse.service";
|
||||
|
||||
@@ -593,6 +596,40 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
yardWork: endpoint<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").YardWorkResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"yard-work",
|
||||
({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId),
|
||||
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
|
||||
),
|
||||
|
||||
loadScheduleBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
import("@/types/trainScheduling").BookingLoadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-load",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.loadScheduleBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
unloadScheduleBooking: endpoint<
|
||||
{ scheduleId: string; bookingId: string },
|
||||
import("@/types/trainScheduling").BookingUnloadResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"booking-unload",
|
||||
({ scheduleId, bookingId }) =>
|
||||
trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
intercityCandidates: endpoint<
|
||||
{ scheduleId: string },
|
||||
import("@/types/trainScheduling").IntercityCandidatesResult
|
||||
@@ -1016,10 +1053,13 @@ export const api = {
|
||||
() => [["warehouse-inventory"], ["warehouses"]],
|
||||
),
|
||||
|
||||
store: endpoint<string, WarehouseInventoryItem>(
|
||||
store: endpoint<
|
||||
{ id: string; payload?: StoreInventoryPayload },
|
||||
WarehouseInventoryItem
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"store",
|
||||
(id) => warehouseService.store(id).then((r) => r.data),
|
||||
({ id, payload }) => warehouseService.store(id, payload).then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
@@ -1493,6 +1533,13 @@ export const api = {
|
||||
wagonService.getById(id).then((r) => r.data),
|
||||
),
|
||||
|
||||
movements: endpoint<{ id: string }, WagonMovementRecord[]>(
|
||||
"wagons",
|
||||
"movements",
|
||||
({ id }) => wagonService.getMovements(id).then((r) => r.data),
|
||||
({ id }) => ["wagons", "movements", id],
|
||||
),
|
||||
|
||||
assignToTrain: endpoint<
|
||||
{ wagonId: string; trainId: string; sequenceNumber?: number },
|
||||
Wagon
|
||||
@@ -2219,13 +2266,13 @@ export const api = {
|
||||
),
|
||||
|
||||
setProfileStatus: endpoint<
|
||||
{ profileId: string; status: ProfileStatus },
|
||||
{ profileId: string; status: ProfileStatus; note?: string },
|
||||
CompanyProfile
|
||||
>(
|
||||
"customers",
|
||||
"setProfileStatus",
|
||||
({ profileId, status }) =>
|
||||
customersService.setProfileStatus(profileId, status),
|
||||
({ profileId, status, note }) =>
|
||||
customersService.setProfileStatus(profileId, status, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
@@ -2233,6 +2280,37 @@ export const api = {
|
||||
],
|
||||
),
|
||||
|
||||
changeRequests: endpoint<{ id: string }, CompanyChangeRequest[]>(
|
||||
"customers",
|
||||
"changeRequests",
|
||||
({ id }) => customersService.changeRequests(id),
|
||||
({ id }) => QUERY_KEYS.CUSTOMERS.changeRequests(id),
|
||||
),
|
||||
|
||||
approveChangeRequest: endpoint<{ id: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"approveChangeRequest",
|
||||
({ id }) => customersService.approveChangeRequest(id),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
rejectChangeRequest: endpoint<{ id: string; note: string }, CompanyChangeRequest>(
|
||||
"customers",
|
||||
"rejectChangeRequest",
|
||||
({ id, note }) => customersService.rejectChangeRequest(id, note),
|
||||
undefined,
|
||||
(_input, data) => [
|
||||
QUERY_KEYS.CUSTOMERS.changeRequests(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.byId(data.companyId),
|
||||
QUERY_KEYS.CUSTOMERS.ROOT,
|
||||
],
|
||||
),
|
||||
|
||||
setCompanyStatus: endpoint<{ companyId: string; status: string }, unknown>(
|
||||
"customers",
|
||||
"setCompanyStatus",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
Company,
|
||||
CompanyChangeRequest,
|
||||
CompanyListFilter,
|
||||
CompanyProfile,
|
||||
CompanyStats,
|
||||
@@ -80,11 +81,15 @@ export const customersService = {
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
setProfileStatus(profileId: string, status: ProfileStatus): Promise<CompanyProfile> {
|
||||
setProfileStatus(
|
||||
profileId: string,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
return apiClient
|
||||
.patch<CompanyProfile>(
|
||||
URL_CONSTANTS.COMPANIES.PROFILE_STATUS(profileId),
|
||||
{ status },
|
||||
{ status, note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
@@ -95,4 +100,32 @@ export const customersService = {
|
||||
.patch(URL_CONSTANTS.COMPANIES.BY_ID(companyId), { status })
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** List a company's profile-edit change requests (newest first). */
|
||||
changeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||
return apiClient
|
||||
.get<CompanyChangeRequest[]>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUESTS(companyId),
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Approve a pending change request — applies the proposed changes. */
|
||||
approveChangeRequest(id: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_APPROVE(id),
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
/** Reject a pending change request with a note. */
|
||||
rejectChangeRequest(id: string, note: string): Promise<CompanyChangeRequest> {
|
||||
return apiClient
|
||||
.post<CompanyChangeRequest>(
|
||||
URL_CONSTANTS.COMPANIES.CHANGE_REQUEST_REJECT(id),
|
||||
{ note },
|
||||
)
|
||||
.then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
AssignBookingsPayload,
|
||||
BookingLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
@@ -35,6 +37,7 @@ import type {
|
||||
UploadImportDjiboutiDocumentPayload,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
YardWorkResult,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
interface BookingReferenceDataResponse {
|
||||
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getYardWork: async (scheduleId: string): Promise<YardWorkResult> => {
|
||||
const response = await client.get<YardWorkResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadScheduleBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<BookingLoadResult> => {
|
||||
const response = await client.post<BookingLoadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
unloadScheduleBooking: async (
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<BookingUnloadResult> => {
|
||||
const response = await client.post<BookingUnloadResult>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getIntercityCandidates: async (
|
||||
scheduleId: string,
|
||||
): Promise<IntercityCandidatesResult> => {
|
||||
|
||||
@@ -37,6 +37,27 @@ export interface WagonListFilters {
|
||||
trainId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the wagon_movements ledger: every physical relocation between
|
||||
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
|
||||
* correction. Returned newest first by the API.
|
||||
*/
|
||||
export interface WagonMovementRecord {
|
||||
id: string;
|
||||
wagonId: string;
|
||||
fromYardId: string | null;
|
||||
toYardId: string;
|
||||
fromYard?: { id?: string; label?: string; code?: string } | null;
|
||||
toYard?: { id?: string; label?: string; code?: string } | null;
|
||||
trainScheduleId: string | null;
|
||||
bookingId: string | null;
|
||||
kind: Freight.WagonMovementKind;
|
||||
movedByUserId: string | null;
|
||||
occurredAt: string;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const wagonService = {
|
||||
getAll: (filters: WagonListFilters = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -49,6 +70,8 @@ export const wagonService = {
|
||||
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
|
||||
getMovements: (id: string) =>
|
||||
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
|
||||
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
StoreInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
@@ -63,7 +64,7 @@ import type {
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED';
|
||||
|
||||
export interface ContainerItem {
|
||||
containerNumber: string;
|
||||
@@ -74,9 +75,12 @@ export interface ContainerItem {
|
||||
truckPlate: string | null;
|
||||
truckArrived: boolean;
|
||||
truckLeft: boolean;
|
||||
/** Operator has loaded this container onto the truck (customer assignment alone is not "loaded"). */
|
||||
loaded: boolean;
|
||||
bookingReference: string | null;
|
||||
contractId: string | null;
|
||||
hasLastMile: boolean;
|
||||
handoverSigned: boolean;
|
||||
}
|
||||
|
||||
/** A pre-dispatch EXPORT train that has inventory waiting to be loaded. */
|
||||
@@ -135,6 +139,26 @@ export const warehouseService = {
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Ask the customer to sign the booking's handover (creates one if none, then notifies). */
|
||||
requestHandoverSignature: async (
|
||||
bookingId: string,
|
||||
): Promise<{ notified: boolean; reference: string | null; alreadySigned: boolean }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/warehouse-inventory/bookings/${bookingId}/request-handover-signature`,
|
||||
);
|
||||
return data?.data ?? data;
|
||||
},
|
||||
|
||||
/** A booking's containers with VGM cargo weight (tonnes) for exit weighing. */
|
||||
getContainerWeights: async (
|
||||
bookingId: string,
|
||||
): Promise<Array<{ containerNumber: string; weightTons: number }>> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/warehouse-inventory/bookings/${bookingId}/container-weights`,
|
||||
);
|
||||
return data?.data ?? data ?? [];
|
||||
},
|
||||
|
||||
/** Booking container numbers not yet loaded onto any truck. */
|
||||
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
|
||||
const { data } = await apiClient.get(
|
||||
@@ -235,8 +259,8 @@ export const warehouseService = {
|
||||
}),
|
||||
|
||||
// ── Lifecycle (Batch 2) ──────────────────────────────────────────────────
|
||||
store: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id)),
|
||||
store: (id: string, payload?: StoreInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.STORE(id), payload),
|
||||
reserve: (payload: ReserveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RESERVE, payload),
|
||||
markReadyForLoading: (id: string) =>
|
||||
|
||||
@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
@@ -206,7 +207,7 @@ export interface BookingDetail {
|
||||
company?: BookingNamedRef & Partial<BookingCompany>;
|
||||
originYard?: BookingNamedRef;
|
||||
destinationYard?: BookingNamedRef;
|
||||
serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
serviceType?: BookingNamedRef & { code?: string; includesCustoms?: boolean; includesFirstMile?: boolean; includesLastMile?: boolean };
|
||||
cargoType?: BookingNamedRef;
|
||||
shippingLine?: BookingNamedRef;
|
||||
bookingContainers?: BookingContainerLine[];
|
||||
@@ -238,7 +239,6 @@ export interface BookingListRow {
|
||||
priorityScore: number;
|
||||
schedulingStatus?: string;
|
||||
serviceTypeLabel?: string;
|
||||
serviceTypeBonus?: number;
|
||||
trainScheduleId?: string | null;
|
||||
isGovernment?: boolean;
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
@@ -30,14 +30,24 @@ export type ProfileType =
|
||||
| "transporter";
|
||||
|
||||
/** Mirrors backend `ProfileStatus`. */
|
||||
export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||
export type ProfileStatus =
|
||||
| "active"
|
||||
| "pending"
|
||||
| "rejected"
|
||||
| "suspended"
|
||||
| "blacklisted";
|
||||
|
||||
/** A business-license document uploaded for a company profile. */
|
||||
/** Review state of a business-license file (mirrors API ProfileLicenseFileView). */
|
||||
export type LicenseFileStatus = "live" | "pending_add" | "pending_remove";
|
||||
|
||||
/** A business-license document uploaded for a company profile (FileRecord-backed). */
|
||||
export interface LicenseFile {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType?: string;
|
||||
mimeType: string;
|
||||
/** `live` = approved; `pending_add`/`pending_remove` = awaiting review. */
|
||||
status: LicenseFileStatus;
|
||||
}
|
||||
|
||||
/** A single role a company is registered for, with its reference code. */
|
||||
@@ -52,6 +62,39 @@ export interface CompanyProfile {
|
||||
/** Business-license documents uploaded for this profile. */
|
||||
licenseFiles?: LicenseFile[];
|
||||
attributes?: Record<string, unknown> | null;
|
||||
/** Reviewer note when the role is rejected. */
|
||||
reviewNote?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Lifecycle of a staged customer profile-edit review. */
|
||||
export type ChangeRequestStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
/** A staged business-license add/remove on one profile, awaiting review. */
|
||||
export interface LicenseChangeIntent {
|
||||
profileId: string;
|
||||
op: "add" | "remove";
|
||||
fileId: string;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A staged profile-edit change request. The customer's settings edits land here
|
||||
* (pending) until a reviewer approves (applies them) or rejects (with a note).
|
||||
*/
|
||||
export interface CompanyChangeRequest {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: ChangeRequestStatus;
|
||||
/** Proposed field values (the diff payload vs. the live company). */
|
||||
snapshot: Record<string, unknown>;
|
||||
documentFileIds: string[];
|
||||
/** Staged business-license add/remove intents attached to this request. */
|
||||
licenseChanges: LicenseChangeIntent[];
|
||||
note: string | null;
|
||||
submittedAt: string | null;
|
||||
reviewedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -118,6 +161,7 @@ export type CustomerBookingStatus =
|
||||
| "APPROVED"
|
||||
| "PAID"
|
||||
| "IN_TRANSIT"
|
||||
| "ARRIVED"
|
||||
| "COMPLETED"
|
||||
| "REJECTED"
|
||||
| "CANCELLED";
|
||||
|
||||
@@ -152,6 +152,8 @@ export interface LocomotiveRecord {
|
||||
|
||||
export interface TrainScheduleListItem {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
createdAt?: string | null;
|
||||
scheduleDate: string;
|
||||
trainNumber?: string | null;
|
||||
routeName?: string | null;
|
||||
@@ -225,6 +227,10 @@ export interface BatchBoardBooking {
|
||||
lengthMeters: number;
|
||||
paymentDeadline: string | null;
|
||||
state: BatchBoardBookingState;
|
||||
/** Rule-engine priority score used to rank the batch (higher = boards first). */
|
||||
priorityScore: number;
|
||||
/** CONTAINER | BULK — for the priority-tracking visuals. */
|
||||
freightType: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,6 +239,7 @@ export interface BatchBoardBooking {
|
||||
*/
|
||||
export interface StaffBookingWindow {
|
||||
scheduleId: string;
|
||||
reference: string | null;
|
||||
trainNumber: string | null;
|
||||
direction: "IMPORT" | "EXPORT" | null;
|
||||
windowPhase: BookingWindowPhase | string | null;
|
||||
@@ -276,6 +283,8 @@ export interface BatchBoardSchedule {
|
||||
maxLengthMeters: number | null;
|
||||
usedWeightTons: number;
|
||||
maxWeightTons: number | null;
|
||||
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
|
||||
maxWagons: number | null;
|
||||
};
|
||||
counts: {
|
||||
allocated: number;
|
||||
@@ -359,6 +368,8 @@ export interface BookingWindow {
|
||||
isOpenNow: boolean;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingWindowStatus: string;
|
||||
bookingCycleNo: number;
|
||||
departureDate: string;
|
||||
@@ -426,6 +437,7 @@ export interface UpdateScheduleWindowRulePayload {
|
||||
|
||||
export interface TrainScheduleDetail {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
status: TrainScheduleStatus | string;
|
||||
deferredBookings?: DeferredBookingRow[];
|
||||
freightType?: FreightType | null;
|
||||
@@ -794,3 +806,52 @@ export interface IntercityAcceptResult {
|
||||
rejected: Array<{ bookingId: string; reason: string }>;
|
||||
remaining: IntercityCapacity;
|
||||
}
|
||||
|
||||
// ── Yard load / unload worklist ──────────────────────────────────────────────
|
||||
// Per-booking journey along the train's corridor: every booking boards at its
|
||||
// origin yard and alights at its destination yard, confirmed by the yard
|
||||
// operator while the train's latest checkpoint is at that yard.
|
||||
|
||||
export interface YardWorkBookingRow {
|
||||
id: string;
|
||||
reference: string | null;
|
||||
status: string;
|
||||
tradeDirection: string;
|
||||
isGovernment: boolean;
|
||||
customer: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
loadedAt: string | null;
|
||||
arrivedAt: string | null;
|
||||
canLoad: boolean;
|
||||
canUnload: boolean;
|
||||
}
|
||||
|
||||
export interface YardWorkYard {
|
||||
yardId: string;
|
||||
yard: string;
|
||||
toLoad: YardWorkBookingRow[];
|
||||
toUnload: YardWorkBookingRow[];
|
||||
}
|
||||
|
||||
export interface YardWorkResult {
|
||||
scheduleId: string;
|
||||
scheduleStatus: string;
|
||||
trainAtYardId: string | null;
|
||||
yards: YardWorkYard[];
|
||||
}
|
||||
|
||||
export interface BookingLoadResult {
|
||||
bookingId: string;
|
||||
status: string;
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
export interface BookingUnloadResult {
|
||||
bookingId: string;
|
||||
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
|
||||
status: string;
|
||||
arrivedAt: string;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,6 @@ export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||
|
||||
export type InventoryAction =
|
||||
| 'store'
|
||||
| 'reserve'
|
||||
| 'ready-for-loading'
|
||||
| 'load'
|
||||
| 'dispatch'
|
||||
@@ -59,7 +58,7 @@ export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | nu
|
||||
UNLOADED: 'store',
|
||||
UNLOADED_AT_DJIBOUTI_PORT: null,
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
STORED: 'ready-for-loading',
|
||||
RESERVED: 'ready-for-loading',
|
||||
ARRIVED_AT_WAREHOUSE: null,
|
||||
UNDER_INSPECTION: null,
|
||||
@@ -85,6 +84,11 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
// Import goods skip storage; they need inspection before pickup.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. Import STORED is handled via the import queue.
|
||||
if (isImport) return null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
@@ -589,12 +593,21 @@ export interface ImportUnloadedItem {
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
hasAssignedTruck: boolean;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
handoverDocumentReference: string | null;
|
||||
handoverDocumentDate: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
/** Optional explicit storage location; blank → backend auto-allocates. */
|
||||
export interface StoreInventoryPayload {
|
||||
warehouseId?: string;
|
||||
yardId?: string;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
@@ -763,6 +776,10 @@ export const FEE_RULE_BASIS_LABELS: Record<FeeRuleBasis, string> = {
|
||||
PER_ITEM: 'Per Item',
|
||||
};
|
||||
|
||||
/** Vehicle types a Truck Detention rule can be scoped to (rates differ by truck type). */
|
||||
export const VEHICLE_TYPES = ['TRUCK', 'VAN', 'CAR', 'BUS', 'TRAILER', 'TANKER', 'FLATBED'] as const;
|
||||
export type VehicleTypeCode = (typeof VEHICLE_TYPES)[number];
|
||||
|
||||
export interface FeeRule {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -774,6 +791,8 @@ export interface FeeRule {
|
||||
tradeDirection?: string | null;
|
||||
cargoTypeCode?: string | null;
|
||||
containerType?: string | null;
|
||||
/** Truck detention only: scope by vehicle type (null = any). */
|
||||
vehicleType?: string | null;
|
||||
facilityId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
yardId?: string | null;
|
||||
@@ -820,6 +839,16 @@ export interface FeePreview {
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
/** Truck detention: per-vehicle-type breakdown. */
|
||||
groups?: Array<{
|
||||
vehicleType: string | null;
|
||||
truckCount: number;
|
||||
chargeableDays: number;
|
||||
ratePerDay: number;
|
||||
amount: number;
|
||||
ruleId: string | null;
|
||||
ruleName: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
|
||||
@@ -8,6 +8,32 @@ export type ApiError = {
|
||||
statusCode?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Backend errors arrive as snake_case i18n-style codes (e.g.
|
||||
* "unable_to_log_in"). Map the known ones to friendly copy and prettify
|
||||
* anything else so raw codes never reach the UI. `code` stays raw for
|
||||
* programmatic checks.
|
||||
*/
|
||||
const API_ERROR_MESSAGES: Record<string, string> = {
|
||||
unable_to_log_in: "Incorrect email or password.",
|
||||
invalid_refresh_token: "Your session has expired. Please sign in again.",
|
||||
session_expired: "Your session has expired. Please sign in again.",
|
||||
session_not_found: "Your session has expired. Please sign in again.",
|
||||
user_not_found: "No account found for these credentials.",
|
||||
};
|
||||
|
||||
const SNAKE_CASE_CODE = /^[a-z0-9]+(?:_[a-z0-9]+)+$/;
|
||||
|
||||
function humanizeApiMessage(raw: string): string {
|
||||
const known = API_ERROR_MESSAGES[raw];
|
||||
if (known) return known;
|
||||
if (SNAKE_CASE_CODE.test(raw)) {
|
||||
const text = raw.replaceAll("_", " ");
|
||||
return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
export function extractApiError(err: unknown): ApiError {
|
||||
if (err && typeof err === "object") {
|
||||
const obj = err as Record<string, unknown>;
|
||||
@@ -16,8 +42,12 @@ export function extractApiError(err: unknown): ApiError {
|
||||
const statusCode = response.status as number | undefined;
|
||||
const data = response.data as Record<string, unknown> | undefined;
|
||||
return {
|
||||
code: (data?.error as string) || (data?.message as string) || "api_error",
|
||||
message: (data?.message as string) || (data?.error as string) || "An unexpected error occurred",
|
||||
code: (data?.message as string) || (data?.error as string) || "api_error",
|
||||
message: humanizeApiMessage(
|
||||
(data?.message as string) ||
|
||||
(data?.error as string) ||
|
||||
"An unexpected error occurred",
|
||||
),
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user