mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
PackageOpen,
|
||||
Paperclip,
|
||||
Receipt,
|
||||
ScrollText,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
@@ -84,6 +85,8 @@ import UserManagementPage from "./pages/dashboard/user-management/UserManagement
|
||||
import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||
@@ -461,6 +464,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Settings />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
{
|
||||
label: "Contract templates",
|
||||
href: "/dashboard/contract-templates",
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1252,6 +1261,22 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractTemplatesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract-templates/:code"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractTemplateEditorPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
@@ -31,7 +32,9 @@ import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
FileDown,
|
||||
FileText,
|
||||
FileUp,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
@@ -54,6 +57,10 @@ import {
|
||||
type GlShipmentQuantities,
|
||||
} from "./gl-booking-form/total";
|
||||
import { ContractCapacityNotice } from "./gl-booking-form/ContractCapacityNotice";
|
||||
import {
|
||||
downloadContainerImportTemplate,
|
||||
parseContainerExcel,
|
||||
} from "./gl-booking-form/container-excel";
|
||||
import {
|
||||
fieldStyles,
|
||||
StepCard,
|
||||
@@ -392,6 +399,57 @@ export default function GlCreateBookingForm() {
|
||||
// bulk needs a positive quantity with hazardous/reefer portions bounded by it.
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
|
||||
// Excel import: one row per container. All-or-nothing — a file with any bad
|
||||
// row is rejected with row-numbered errors so nothing is silently dropped.
|
||||
const [importErrors, setImportErrors] = useState<string[]>([]);
|
||||
const [importSummary, setImportSummary] = useState<string | null>(null);
|
||||
const importResetRef = useRef<(() => void) | null>(null);
|
||||
const excelOpts = {
|
||||
allowedSizes: containerSizes,
|
||||
includeHazardous: contract?.isHazardous ?? false,
|
||||
includeReefer: contract?.isReefer ?? false,
|
||||
};
|
||||
|
||||
const handleImportFile = async (file: File | null) => {
|
||||
// Reset the hidden input so re-picking the same (fixed) file re-fires.
|
||||
importResetRef.current?.();
|
||||
if (!file) return;
|
||||
const { rows, errors } = await parseContainerExcel(file, excelOpts);
|
||||
if (errors.length > 0) {
|
||||
setImportSummary(null);
|
||||
setImportErrors(errors);
|
||||
return;
|
||||
}
|
||||
// Replace only the lines for sizes present in the file; a contracted size
|
||||
// the file omits keeps whatever was already entered for it.
|
||||
setContainerLines((prev) =>
|
||||
containerSizes.map((size) => {
|
||||
const imported = rows.filter((r) => r.containerSize === size);
|
||||
if (imported.length === 0) {
|
||||
return (
|
||||
prev.find((l) => l.containerSize === size) ?? {
|
||||
containerSize: size,
|
||||
units: [emptyUnit()],
|
||||
}
|
||||
);
|
||||
}
|
||||
return {
|
||||
containerSize: size,
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
vgmTons: r.vgmTons,
|
||||
hazardous: r.hazardous,
|
||||
reefer: r.reefer,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
);
|
||||
setImportErrors([]);
|
||||
setShowErrors(false);
|
||||
setImportSummary(`Imported ${rows.length} container(s) from ${file.name}.`);
|
||||
};
|
||||
|
||||
const unitErrors = useMemo<UnitErrors[][]>(() => {
|
||||
if (!isContainer) return [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
@@ -740,6 +798,83 @@ export default function GlCreateBookingForm() {
|
||||
description="Enter the quantity and per-container details for each size in the contract scope."
|
||||
/>
|
||||
<Stack gap={18}>
|
||||
{containerSizes.length > 0 && (
|
||||
<Paper withBorder radius="md" p="md" style={{ borderColor: "#E6ECF2" }}>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Box>
|
||||
<Text fz={13} fw={600}>
|
||||
Import containers from Excel
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
One row per container. Importing fills the lines below
|
||||
for the sizes in the file.
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<FileDown size={14} />}
|
||||
onClick={() => downloadContainerImportTemplate(excelOpts)}
|
||||
>
|
||||
Download template
|
||||
</Button>
|
||||
<FileButton
|
||||
resetRef={importResetRef}
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleImportFile}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
size="xs"
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
leftSection={<FileUp size={14} />}
|
||||
>
|
||||
Import Excel
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
</Group>
|
||||
{importErrors.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={16} />}
|
||||
title="Import failed — fix the file and try again"
|
||||
mt="sm"
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{importErrors.slice(0, 8).map((msg, i) => (
|
||||
<Text key={i} fz="xs">
|
||||
{msg}
|
||||
</Text>
|
||||
))}
|
||||
{importErrors.length > 8 && (
|
||||
<Text fz="xs" c="dimmed">
|
||||
…and {importErrors.length - 8} more.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{importSummary && (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
mt="sm"
|
||||
>
|
||||
<Text fz="xs">{importSummary}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for container shipments: one spreadsheet row per physical
|
||||
// container, mirroring the manual per-unit fields (number, seal, VGM) plus the
|
||||
// hazardous/reefer flags when the contract allows them. The parser is
|
||||
// all-or-nothing — any bad row rejects the file with row-numbered errors so a
|
||||
// partial import can never silently drop containers.
|
||||
|
||||
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ContainerExcelOptions {
|
||||
/** Container sizes the contract scope allows (e.g. ["20ft", "40ft"]). */
|
||||
allowedSizes: string[];
|
||||
includeHazardous: boolean;
|
||||
includeReefer: boolean;
|
||||
}
|
||||
|
||||
export interface ImportedContainerRow {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: string;
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerExcelResult {
|
||||
rows: ImportedContainerRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerSize"
|
||||
| "containerNumber"
|
||||
| "sealNumber"
|
||||
| "vgmTons"
|
||||
| "hazardous"
|
||||
| "reefer";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/units. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size")) return "containerSize";
|
||||
if (h.includes("seal")) return "sealNumber";
|
||||
if (h.includes("vgm") || h.includes("weight")) return "vgmTons";
|
||||
if (h.includes("hazard")) return "hazardous";
|
||||
if (h.includes("reefer") || h.includes("refrigerat")) return "reefer";
|
||||
// After the more specific matches: "Container Number", "Container No", …
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "20 FT" … → the matching contracted size, or null. */
|
||||
function normalizeSize(raw: string, allowed: string[]): string | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (!digits) return null;
|
||||
return allowed.find((s) => s.replace(/[^0-9]/g, "") === digits) ?? null;
|
||||
}
|
||||
|
||||
function parseFlag(raw: string): boolean {
|
||||
const v = raw.trim().toLowerCase();
|
||||
return v === "yes" || v === "y" || v === "true" || v === "1" || v === "x";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per container. Returns either the
|
||||
* full row set or the list of row-numbered problems (never both).
|
||||
*/
|
||||
export async function parseContainerExcel(
|
||||
file: File,
|
||||
opts: ContainerExcelOptions,
|
||||
): Promise<ContainerExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) {
|
||||
return { rows: [], errors: ["The file has no sheets."] };
|
||||
}
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
raw: false,
|
||||
defval: "",
|
||||
});
|
||||
|
||||
// First row with a recognizable column is the header; everything above
|
||||
// (titles, blank rows) is ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber") && mapped.includes("containerSize")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find the expected columns. The sheet needs at least "Container Size" and "Container Number" headers — download the template to see the format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!columns.includes("vgmTons")) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: ['Missing a "VGM (Tons)" column — download the template to see the format.'],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ImportedContainerRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const size = normalizeSize(cell("containerSize"), opts.allowedSizes);
|
||||
if (!size) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: container size "${cell("containerSize") || "—"}" is not in this contract's scope (allowed: ${opts.allowedSizes.join(", ")}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase();
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. MSCU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const vgmRaw = cell("vgmTons");
|
||||
const vgm = Number(vgmRaw);
|
||||
if (!vgmRaw || Number.isNaN(vgm) || vgm <= 0) {
|
||||
errors.push(`Row ${rowNo}: VGM "${vgmRaw || "—"}" must be a number greater than 0.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerSize: size ?? "",
|
||||
containerNumber,
|
||||
sealNumber: cell("sealNumber"),
|
||||
vgmTons: vgmRaw,
|
||||
hazardous: opts.includeHazardous && parseFlag(cell("hazardous")),
|
||||
reefer: opts.includeReefer && parseFlag(cell("reefer")),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Generate and download the simple import template with one sample row per size. */
|
||||
export function downloadContainerImportTemplate(opts: ContainerExcelOptions) {
|
||||
const headers = ["Container Size", "Container Number", "Seal Number", "VGM (Tons)"];
|
||||
if (opts.includeHazardous) headers.push("Hazardous (YES/NO)");
|
||||
if (opts.includeReefer) headers.push("Reefer (YES/NO)");
|
||||
|
||||
const sizes = opts.allowedSizes.length > 0 ? opts.allowedSizes : ["20ft"];
|
||||
const sampleRows = sizes.map((size, i) => {
|
||||
const row: Array<string | number> = [
|
||||
size,
|
||||
`MSCU${String(1234567 + i).padStart(7, "0")}`,
|
||||
`SL${String(482910 + i)}`,
|
||||
size.startsWith("40") ? 28 : 24.5,
|
||||
];
|
||||
if (opts.includeHazardous) row.push("NO");
|
||||
if (opts.includeReefer) row.push("NO");
|
||||
return row;
|
||||
});
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, ...sampleRows]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 16) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Containers");
|
||||
XLSX.writeFile(workbook, "container-import-template.xlsx");
|
||||
}
|
||||
@@ -295,9 +295,9 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
}, [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;
|
||||
// Wagon-slot cap from the board DTO (derived from train length and the
|
||||
// shortest wagon type); null on legacy rows without a computable cap.
|
||||
const maxWagons: number | null = data.capacity.maxWagons ?? 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.
|
||||
@@ -431,23 +431,31 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
<Text size="xs" fw={700}>
|
||||
{data.capacity.allocatedWagons} allocated ·{" "}
|
||||
{capUsed} in batch
|
||||
{maxWagons != null ? ` · ${maxWagons} max` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* Scale against the real wagon cap when the DTO carries one; fall back
|
||||
to the in-batch total on legacy rows without a computable cap. */}
|
||||
<Progress.Root size="lg" radius="xl">
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
? Math.min(100, (data.capacity.allocatedWagons / capUsed) * 100)
|
||||
(maxWagons ?? capUsed) > 0
|
||||
? Math.min(
|
||||
100,
|
||||
(data.capacity.allocatedWagons / (maxWagons ?? capUsed)) * 100,
|
||||
)
|
||||
: 0
|
||||
}
|
||||
color="edr-green"
|
||||
/>
|
||||
<Progress.Section
|
||||
value={
|
||||
capUsed > 0
|
||||
(maxWagons ?? capUsed) > 0
|
||||
? Math.min(
|
||||
100,
|
||||
((capUsed - data.capacity.allocatedWagons) / capUsed) * 100,
|
||||
((capUsed - data.capacity.allocatedWagons) /
|
||||
(maxWagons ?? capUsed)) *
|
||||
100,
|
||||
)
|
||||
: 0
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type DiagramWagonInput = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons?: number | null;
|
||||
slotLoadType?: string | null;
|
||||
wagonType?: { code?: string | null } | null;
|
||||
wagonTypeCode?: string | null;
|
||||
@@ -33,6 +34,7 @@ type NormalizedWagon = {
|
||||
sequenceNo: number;
|
||||
capacityTons: number;
|
||||
assignedWeightTons: number;
|
||||
tareWeightTons: number;
|
||||
wagonTypeCode: string | null;
|
||||
physicalWagonNumber: string | null;
|
||||
isEmpty: boolean;
|
||||
@@ -69,6 +71,7 @@ function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): Norm
|
||||
sequenceNo: w.sequenceNo,
|
||||
capacityTons: Number(w.capacityTons) || 0,
|
||||
assignedWeightTons: Number(w.assignedWeightTons) || 0,
|
||||
tareWeightTons: Number(w.tareWeightTons) || 0,
|
||||
wagonTypeCode: w.wagonType?.code ?? w.wagonTypeCode ?? null,
|
||||
physicalWagonNumber: w.physicalWagonNumber ?? null,
|
||||
isEmpty: allocations.length === 0,
|
||||
@@ -278,7 +281,9 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
|
||||
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
|
||||
}${
|
||||
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)`;
|
||||
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
|
||||
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
|
||||
}`;
|
||||
|
||||
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
|
||||
const blocks = wagon.containerNumbers.slice(0, 2);
|
||||
@@ -523,15 +528,22 @@ export function TrainCompositionDiagram({
|
||||
const assigned = normalized.filter((w) => !w.isEmpty).length;
|
||||
const totalWeight = normalized.reduce((s, w) => s + w.assignedWeightTons, 0);
|
||||
const totalCapacity = normalized.reduce((s, w) => s + w.capacityTons, 0);
|
||||
// Every coupled wagon's tare is hauled — empty ones included — so the
|
||||
// locomotive pull limit is measured against gross (tare + cargo), the same
|
||||
// ceiling the allocation engine spends from.
|
||||
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
|
||||
const grossWeight = totalWeight + totalTare;
|
||||
return {
|
||||
total: normalized.length,
|
||||
assigned,
|
||||
empty: normalized.length - assigned,
|
||||
totalWeight: Math.round(totalWeight * 100) / 100,
|
||||
totalTare: Math.round(totalTare * 100) / 100,
|
||||
grossWeight: Math.round(grossWeight * 100) / 100,
|
||||
totalCapacity,
|
||||
pullUtil:
|
||||
locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
|
||||
? Math.min(100, Math.round((totalWeight / locomotive.maxPullWeightTons) * 100))
|
||||
? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
|
||||
: null,
|
||||
};
|
||||
}, [normalized, locomotive]);
|
||||
@@ -598,12 +610,30 @@ export function TrainCompositionDiagram({
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.totalWeight}T
|
||||
{stats.totalWeight}T cargo
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
of {stats.totalCapacity}T capacity
|
||||
</Text>
|
||||
</Group>
|
||||
{stats.totalTare > 0 ? (
|
||||
<Group
|
||||
gap={6}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="dark.4">
|
||||
{stats.grossWeight}T gross
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
incl. {stats.totalTare}T tare
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Group gap="xs">
|
||||
<LegendDot color="cyan" label="Container" />
|
||||
<LegendDot color="orange" label="Bulk" />
|
||||
@@ -626,7 +656,10 @@ export function TrainCompositionDiagram({
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Gauge size={14} color={freightBrand.primary} />
|
||||
<Text size="xs" fw={700} c="edr-green.8">
|
||||
Locomotive load · {stats.totalWeight}T of {locomotive?.maxPullWeightTons}T
|
||||
Locomotive load ·{" "}
|
||||
{stats.totalTare > 0
|
||||
? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
|
||||
: `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={800} c={stats.pullUtil > 95 ? "red.7" : "edr-green.7"}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import type { FeePreview, WarehouseInventoryItem, WarehouseInvoiceStatus } from '@/types/warehouse';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
@@ -157,7 +157,7 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
|
||||
description: `Release paper could not be opened: ${await extractDownloadErrorMessage(documentError)}`,
|
||||
});
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface InventoryWorkbenchProps {
|
||||
@@ -111,7 +111,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Release paper preview failed',
|
||||
description: extractErrorMessage(error),
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
@@ -131,7 +131,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Handover document failed',
|
||||
description: extractErrorMessage(error),
|
||||
description: await extractDownloadErrorMessage(error),
|
||||
});
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
|
||||
@@ -76,7 +76,7 @@ import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { StoreInventoryModal } from './StoreInventoryModal';
|
||||
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
@@ -112,7 +112,7 @@ function GrnDocumentButton({ inventoryId, grnNumber }: { inventoryId: string; gr
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -150,9 +150,6 @@ interface TruckEntranceFormState {
|
||||
assignedEquipmentNumber: string;
|
||||
customsSealNumber: string;
|
||||
declarationNumber: string;
|
||||
incoterms: string;
|
||||
hsCodes: string;
|
||||
itemCode: string;
|
||||
itemDescription: string;
|
||||
packagingType: string;
|
||||
unitCount: number | '';
|
||||
@@ -162,7 +159,6 @@ interface TruckEntranceFormState {
|
||||
volumeDimensions: string;
|
||||
conditionAtReceipt: string;
|
||||
damagedRejectedQuantity: number | '';
|
||||
warehouseCodeLocation: string;
|
||||
driverName: string;
|
||||
driverPhone: string;
|
||||
driverLicenseNumber: string;
|
||||
@@ -205,9 +201,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
assignedEquipmentNumber: '',
|
||||
customsSealNumber: '',
|
||||
declarationNumber: '',
|
||||
incoterms: '',
|
||||
hsCodes: '',
|
||||
itemCode: '',
|
||||
itemDescription: '',
|
||||
packagingType: '',
|
||||
unitCount: '',
|
||||
@@ -217,7 +210,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
volumeDimensions: '',
|
||||
conditionAtReceipt: '',
|
||||
damagedRejectedQuantity: '',
|
||||
warehouseCodeLocation: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
driverLicenseNumber: '',
|
||||
@@ -239,9 +231,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
@@ -251,7 +240,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity),
|
||||
warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined,
|
||||
driverName: form.driverName.trim(),
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
@@ -296,6 +284,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
const truckType = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
|
||||
);
|
||||
const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers));
|
||||
// Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed.
|
||||
const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN;
|
||||
const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : '';
|
||||
const edrDigitalBookingId =
|
||||
bookings.length === 1
|
||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||
@@ -321,9 +313,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
customerPhone,
|
||||
edrDigitalBookingId,
|
||||
assignedEquipmentNumber,
|
||||
customsSealNumber,
|
||||
itemDescription,
|
||||
packagingType,
|
||||
unitCount,
|
||||
netWeightKg,
|
||||
grossWeightKg: '',
|
||||
truckPlateNumber,
|
||||
trailerPlateNumber,
|
||||
@@ -331,6 +325,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
driverPhone,
|
||||
driverLicenseNumber,
|
||||
truckType,
|
||||
driverSignatoryName: driverName,
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
@@ -550,38 +545,19 @@ function TruckEntranceFields({
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Declaration / Bill of Entry number"
|
||||
value={value.declarationNumber}
|
||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Incoterms"
|
||||
value={value.incoterms}
|
||||
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="HS codes"
|
||||
value={value.hsCodes}
|
||||
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
|
||||
label="Declaration / Bill of Entry number"
|
||||
value={value.declarationNumber}
|
||||
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Item code"
|
||||
value={value.itemCode}
|
||||
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Packaging type"
|
||||
@@ -626,11 +602,6 @@ function TruckEntranceFields({
|
||||
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Warehouse code and location"
|
||||
value={value.warehouseCodeLocation}
|
||||
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Driver signatory"
|
||||
@@ -900,7 +871,7 @@ function EligibleTab({
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
@@ -1697,7 +1668,6 @@ function LoadedExportTab({
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
{dispatchable && <Table.Th ta="right">Actions</Table.Th>}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -1739,19 +1709,6 @@ function LoadedExportTab({
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
{dispatchable && (
|
||||
<Table.Td ta="right">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={bulkDispatch.isPending}
|
||||
onClick={() => dispatch([r.id])}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
@@ -2245,6 +2202,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
// Carries the saved [Exit Inspection] block so Truck Leaving opens with the
|
||||
// arrival details (plate, driver, tare, gate-in) read-only instead of blank.
|
||||
notes: row.notes,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
@@ -2282,7 +2242,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'Handover document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -2296,7 +2256,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
openPdfBlob(response.data, `release-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'Exit paper failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Group, Modal, MultiSelect, NumberInput, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -105,6 +105,7 @@ const parseInspectionNote = (notes: string | null | undefined) => {
|
||||
grossWeight: lineNumber(note, 'Gross Weight'),
|
||||
netWeight: lineNumber(note, 'Net Weight'),
|
||||
gateOutTime: toLocalDateTimeInput(lineValue(note, 'Gate Out Time')),
|
||||
weighingSkipped: /^Weighing:\s*SKIPPED/im.test(note ?? ''),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -135,6 +136,8 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const [containerNumbers, setContainerNumbers] = useState<string[]>(['']);
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
// Containers may skip the weighbridge (decided at arrival, sticks for exit). Bulk always weighs.
|
||||
const [weighTruck, setWeighTruck] = useState<'yes' | 'no'>('yes');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
@@ -158,6 +161,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setWeighTruck(inspection.weighingSkipped ? 'no' : 'yes');
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
setNetWeight(item?.weight == null ? inspection.netWeight : Number(item.weight));
|
||||
setGateOutTime(inspection.gateOutTime);
|
||||
@@ -165,7 +169,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
}, [opened, item, truckPrefill]);
|
||||
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isExitStep = savedInspection.tareWeight !== '' || savedInspection.weighingSkipped;
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
|
||||
@@ -220,7 +224,9 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
.reduce((sum, n) => sum + (containerWeightByNumber.get(n.toUpperCase()) ?? 0), 0)
|
||||
.toFixed(3),
|
||||
);
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0;
|
||||
// Skip is only offered for container bookings; bulk always weighs.
|
||||
const skipWeighing = hasContainerWeights && weighTruck === 'no';
|
||||
const useContainerNet = hasContainerWeights && selectedContainerNumbers.length > 0 && !skipWeighing;
|
||||
|
||||
const systemNetWeight = useContainerNet
|
||||
? selectedCargoWeight
|
||||
@@ -230,6 +236,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
!skipWeighing &&
|
||||
computedNetWeight != null && systemNetWeight !== '' && Math.abs(Number(systemNetWeight) - computedNetWeight) > 0.001;
|
||||
const title = isExitStep ? 'Customer truck leaving and exit weighing' : 'Customer truck arrival weighing';
|
||||
|
||||
@@ -239,19 +246,25 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (!gateInTime || tareWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Gate in time and tare weight are required' });
|
||||
if (!gateInTime || (!skipWeighing && tareWeight === '')) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: skipWeighing ? 'Gate in time is required' : 'Gate in time and tare weight are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || grossWeight === '')) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time and gross weight are required' });
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: skipWeighing ? 'Gate out time is required' : '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 === '') {
|
||||
if (isExitStep && !skipWeighing && systemNetWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'System recorded net weight is missing' });
|
||||
return;
|
||||
}
|
||||
@@ -279,9 +292,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumbers.map((number) => number.trim()).filter(Boolean).join(', ') || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
weighingSkipped: skipWeighing || undefined,
|
||||
tareWeight: skipWeighing ? undefined : Number(tareWeight),
|
||||
grossWeight: skipWeighing || grossWeight === '' ? undefined : Number(grossWeight),
|
||||
netWeight: !skipWeighing && isExitStep && systemNetWeight !== '' ? Number(systemNetWeight) : undefined,
|
||||
gateOutTime: isExitStep ? toIsoDateTime(gateOutTime) : undefined,
|
||||
},
|
||||
});
|
||||
@@ -421,9 +435,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
<Text size="sm" fw={600}>Weigh truck?</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
data={[{ value: 'yes', label: 'Yes — weigh' }, { value: 'no', label: 'No — pass' }]}
|
||||
value={weighTruck}
|
||||
onChange={(v) => setWeighTruck((v as 'yes' | 'no') ?? 'yes')}
|
||||
disabled={isEntranceLocked}
|
||||
/>
|
||||
{skipWeighing && (
|
||||
<Text size="xs" c="dimmed">Weighbridge skipped — container passes without tare/gross.</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
<Group grow>
|
||||
<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="Tare weight (t)" required={!skipWeighing} min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} readOnly={isEntranceLocked} disabled={skipWeighing} />
|
||||
<NumberInput label="Gross weight (t)" required={isExitStep && !skipWeighing} min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} disabled={!isExitStep || skipWeighing} />
|
||||
<NumberInput
|
||||
label={useContainerNet ? 'Selected cargo net (t)' : 'Recorded net weight (system t)'}
|
||||
min={0}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from 'react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { extractErrorMessage } from './options';
|
||||
import { extractDownloadErrorMessage, extractErrorMessage } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface TruckDispatchModalProps {
|
||||
@@ -56,7 +56,7 @@ export function TruckDispatchModal({ opened, onClose, bookingId, bookingReferenc
|
||||
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) });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type WarehouseInventoryItem,
|
||||
} from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { extractErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
|
||||
import { openPdfBlob } from './pdf';
|
||||
|
||||
interface WarehouseInventoryTableProps {
|
||||
@@ -78,7 +78,7 @@ function GrnDocumentButton({ item }: { item: WarehouseInventoryItem }) {
|
||||
toast({ title: opened ? 'GRN document opened' : 'GRN document downloaded' });
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: extractErrorMessage(error) });
|
||||
toast({ variant: 'destructive', title: 'GRN document failed', description: await extractDownloadErrorMessage(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -160,7 +160,12 @@ export function WarehouseInventoryTable({
|
||||
{items.map((item) => {
|
||||
const kind = itemKind(item);
|
||||
const busy = busyId === item.id;
|
||||
const nextAction = getNextInventoryAction(item);
|
||||
// Per-booking Load and Dispatch are retired: wagon loading happens in
|
||||
// the train flow and dispatch at the train level (which already
|
||||
// advances inventory). Only the remaining lifecycle actions render.
|
||||
const rawNextAction = getNextInventoryAction(item);
|
||||
const nextAction =
|
||||
rawNextAction === 'load' || rawNextAction === 'dispatch' ? null : rawNextAction;
|
||||
const canGenerateHandover =
|
||||
item.inspectionStatus === 'PASSED' &&
|
||||
Boolean(item.bookingId) &&
|
||||
@@ -232,26 +237,15 @@ export function WarehouseInventoryTable({
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'dispatch')}
|
||||
>
|
||||
Dispatch
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, 'store')}
|
||||
>
|
||||
Store
|
||||
</Button>
|
||||
)}
|
||||
{item.status !== 'DISPATCHED' && (
|
||||
<Tooltip label="Move" withArrow>
|
||||
|
||||
@@ -103,7 +103,8 @@ export const QUERY_KEYS = {
|
||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
batchBoard: () => ["train-scheduling", "batch-board"] as const,
|
||||
batchBoard: (filters?: unknown) =>
|
||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
["train-scheduling", "batch-board", scheduleId] as const,
|
||||
unassignedBookings: (id: string) =>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function toContractListRow(contract: Freight.IContract): ContractListRow
|
||||
approvalSteps: contract.approvalSteps,
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
: (contract.company?.name ?? "—"),
|
||||
status: contract.status,
|
||||
contractKind: contract.contractKind,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
contractTemplatesService,
|
||||
type ArticlePayload,
|
||||
type UpdateContractTemplatePayload,
|
||||
} from "@/services/contract-templates.service";
|
||||
|
||||
const KEYS = {
|
||||
ROOT: ["contract-templates"] as const,
|
||||
list: () => ["contract-templates", "list"] as const,
|
||||
byCode: (code: string) => ["contract-templates", "detail", code] as const,
|
||||
preview: (code: string) => ["contract-templates", "preview", code] as const,
|
||||
};
|
||||
|
||||
export function useContractTemplates() {
|
||||
return useQuery({
|
||||
queryKey: KEYS.list(),
|
||||
queryFn: () => contractTemplatesService.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useContractTemplate(code: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: KEYS.byCode(code ?? ""),
|
||||
queryFn: () => contractTemplatesService.getByCode(code as string),
|
||||
enabled: Boolean(code),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rendered mock-data HTML preview of the template's saved state. */
|
||||
export function useContractTemplatePreview(code: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: KEYS.preview(code ?? ""),
|
||||
queryFn: () => contractTemplatesService.preview(code as string),
|
||||
enabled: Boolean(code) && enabled,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function useTemplateMutation<TVariables>(
|
||||
mutationFn: (vars: TVariables) => Promise<unknown>,
|
||||
successMessage: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: () => {
|
||||
toast.success(successMessage);
|
||||
void queryClient.invalidateQueries({ queryKey: KEYS.ROOT });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Something went wrong";
|
||||
toast.error(Array.isArray(message) ? message.join(", ") : message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateContractTemplate(code: string) {
|
||||
return useTemplateMutation(
|
||||
(payload: UpdateContractTemplatePayload) =>
|
||||
contractTemplatesService.update(code, payload),
|
||||
"Template updated",
|
||||
);
|
||||
}
|
||||
|
||||
export function useAddArticle(code: string) {
|
||||
return useTemplateMutation(
|
||||
(payload: ArticlePayload) => contractTemplatesService.addArticle(code, payload),
|
||||
"Article added",
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdateArticle(code: string) {
|
||||
return useTemplateMutation(
|
||||
(vars: { articleId: string; payload: Partial<ArticlePayload> }) =>
|
||||
contractTemplatesService.updateArticle(code, vars.articleId, vars.payload),
|
||||
"Article updated",
|
||||
);
|
||||
}
|
||||
|
||||
export function useRemoveArticle(code: string) {
|
||||
return useTemplateMutation(
|
||||
(articleId: string) => contractTemplatesService.removeArticle(code, articleId),
|
||||
"Article removed",
|
||||
);
|
||||
}
|
||||
|
||||
export function useReplaceArticles(code: string) {
|
||||
return useTemplateMutation(
|
||||
(articles: Array<{ id?: string; title: string; body: string }>) =>
|
||||
contractTemplatesService.replaceArticles(code, articles),
|
||||
"Articles reordered",
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export const queryClient = new QueryClient({
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30_000,
|
||||
// Data freshness is driven by mutation invalidation (MutationCache above),
|
||||
// socket pushes, and explicit polling — not by tab focus. Focus refetch
|
||||
// just re-fires every mounted query each time the window is refocused.
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,18 +4,21 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FilterX,
|
||||
LayoutList,
|
||||
Package,
|
||||
Plus,
|
||||
@@ -26,6 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
useBookingList,
|
||||
useBookingListSummary,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import type { BookingListRow } from "@/types/booking";
|
||||
import {
|
||||
@@ -77,6 +82,33 @@ const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
const PAYMENT_STATUS_OPTIONS = [
|
||||
{ value: "PENDING", label: "Payment pending" },
|
||||
{ value: "PNR_GENERATED", label: "PNR generated" },
|
||||
{ value: "VERIFICATION_IN_PROGRESS", label: "Verification in progress" },
|
||||
{ value: "PAID", label: "Paid" },
|
||||
{ value: "FAILED", label: "Payment failed" },
|
||||
];
|
||||
|
||||
const OWNERSHIP_OPTIONS = [
|
||||
{ value: "true", label: "Government" },
|
||||
{ value: "false", label: "Private" },
|
||||
];
|
||||
|
||||
/** Local start-of-day → ISO, for inclusive "from" date filters. */
|
||||
function startOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
/** Local end-of-day → ISO, for inclusive "to" date filters. */
|
||||
function endOfDayIso(d: Date): string {
|
||||
const x = new Date(d);
|
||||
x.setHours(23, 59, 59, 999);
|
||||
return x.toISOString();
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -95,10 +127,18 @@ export default function BookingRequestsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
|
||||
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
|
||||
// Per-tab filter selects (each nullable = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
// Per-tab filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
const [originYardFilter, setOriginYardFilter] = useState<string | null>(null);
|
||||
const [destinationYardFilter, setDestinationYardFilter] = useState<string | null>(null);
|
||||
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
|
||||
const [createdTo, setCreatedTo] = useState<Date | null>(null);
|
||||
const [scheduledFrom, setScheduledFrom] = useState<Date | null>(null);
|
||||
const [scheduledTo, setScheduledTo] = useState<Date | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
const suppressRowClickRef = useRef(false);
|
||||
@@ -118,9 +158,21 @@ export default function BookingRequestsPage() {
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
...(statusFilter ? { statuses: statusFilter } : {}),
|
||||
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
...(paymentStatusFilter ? { paymentStatus: paymentStatusFilter } : {}),
|
||||
...(ownershipFilter
|
||||
? { isGovernment: ownershipFilter as "true" | "false" }
|
||||
: {}),
|
||||
...(originYardFilter ? { originYardId: originYardFilter } : {}),
|
||||
...(destinationYardFilter
|
||||
? { destinationYardId: destinationYardFilter }
|
||||
: {}),
|
||||
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
|
||||
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
|
||||
...(scheduledFrom ? { scheduledFrom: startOfDayIso(scheduledFrom) } : {}),
|
||||
...(scheduledTo ? { scheduledTo: endOfDayIso(scheduledTo) } : {}),
|
||||
};
|
||||
}, [
|
||||
pagination.pageIndex,
|
||||
@@ -129,6 +181,14 @@ export default function BookingRequestsPage() {
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
paymentStatusFilter,
|
||||
ownershipFilter,
|
||||
originYardFilter,
|
||||
destinationYardFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
scheduledFrom,
|
||||
scheduledTo,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
@@ -142,6 +202,49 @@ export default function BookingRequestsPage() {
|
||||
refetch: refetchSummary,
|
||||
} = useBookingListSummary(filter);
|
||||
|
||||
// Yard options for the origin/destination filters (shared routes reference list).
|
||||
const { data: yardRefs } = useQuery(
|
||||
api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }),
|
||||
);
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
(yardRefs ?? []).map((y) => ({
|
||||
value: y.id,
|
||||
label: y.label ?? y.code,
|
||||
})),
|
||||
[yardRefs],
|
||||
);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}, [setPagination, pagination.pageSize]);
|
||||
|
||||
const activeFilterCount =
|
||||
(statusFilter.length ? 1 : 0) +
|
||||
(directionFilter ? 1 : 0) +
|
||||
(freightTypeFilter ? 1 : 0) +
|
||||
(paymentStatusFilter ? 1 : 0) +
|
||||
(ownershipFilter ? 1 : 0) +
|
||||
(originYardFilter ? 1 : 0) +
|
||||
(destinationYardFilter ? 1 : 0) +
|
||||
(createdFrom || createdTo ? 1 : 0) +
|
||||
(scheduledFrom || scheduledTo ? 1 : 0);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setStatusFilter([]);
|
||||
setDirectionFilter(null);
|
||||
setFreightTypeFilter(null);
|
||||
setPaymentStatusFilter(null);
|
||||
setOwnershipFilter(null);
|
||||
setOriginYardFilter(null);
|
||||
setDestinationYardFilter(null);
|
||||
setCreatedFrom(null);
|
||||
setCreatedTo(null);
|
||||
setScheduledFrom(null);
|
||||
setScheduledTo(null);
|
||||
resetPage();
|
||||
}, [resetPage]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const items = (data?.items ?? []).map(toBookingListRow);
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -415,18 +518,44 @@ export default function BookingRequestsPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
<MultiSelect
|
||||
placeholder={statusFilter.length ? undefined : "All statuses"}
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 200 }}
|
||||
style={{ minWidth: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All origins"
|
||||
data={yardOptions}
|
||||
value={originYardFilter}
|
||||
onChange={(v) => {
|
||||
setOriginYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All destinations"
|
||||
data={yardOptions}
|
||||
value={destinationYardFilter}
|
||||
onChange={(v) => {
|
||||
setDestinationYardFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
@@ -434,11 +563,11 @@ export default function BookingRequestsPage() {
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
@@ -446,13 +575,98 @@ export default function BookingRequestsPage() {
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All payment statuses"
|
||||
data={PAYMENT_STATUS_OPTIONS}
|
||||
value={paymentStatusFilter}
|
||||
onChange={(v) => {
|
||||
setPaymentStatusFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 180 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Gov / Private"
|
||||
data={OWNERSHIP_OPTIONS}
|
||||
value={ownershipFilter}
|
||||
onChange={(v) => {
|
||||
setOwnershipFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created from"
|
||||
value={createdFrom}
|
||||
onChange={(v) => {
|
||||
setCreatedFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={createdTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Created to"
|
||||
value={createdTo}
|
||||
onChange={(v) => {
|
||||
setCreatedTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={createdFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 140 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled from"
|
||||
value={scheduledFrom}
|
||||
onChange={(v) => {
|
||||
setScheduledFrom(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
maxDate={scheduledTo ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
<DateInput
|
||||
placeholder="Scheduled to"
|
||||
value={scheduledTo}
|
||||
onChange={(v) => {
|
||||
setScheduledTo(v ? new Date(v) : null);
|
||||
resetPage();
|
||||
}}
|
||||
minDate={scheduledFrom ?? undefined}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
{activeFilterCount > 0 ? (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="lg"
|
||||
leftSection={<FilterX size={16} />}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear filters ({activeFilterCount})
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import {
|
||||
useAddArticle,
|
||||
useContractTemplate,
|
||||
useContractTemplatePreview,
|
||||
useRemoveArticle,
|
||||
useReplaceArticles,
|
||||
useUpdateArticle,
|
||||
useUpdateContractTemplate,
|
||||
} from "@/hooks/contract-templates/useContractTemplates";
|
||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||
|
||||
const BODY_HINT =
|
||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Placeholders like {{client.companyName}}, {{contractDate}}, {{contractYear}} and {{reference}} are filled from the contract.';
|
||||
|
||||
interface ArticleDraft {
|
||||
id?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export default function ContractTemplateEditorPage() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const { data: template, isLoading } = useContractTemplate(code);
|
||||
const preview = useContractTemplatePreview(code);
|
||||
|
||||
const updateTemplate = useUpdateContractTemplate(code ?? "");
|
||||
const addArticle = useAddArticle(code ?? "");
|
||||
const updateArticle = useUpdateArticle(code ?? "");
|
||||
const removeArticle = useRemoveArticle(code ?? "");
|
||||
const replaceArticles = useReplaceArticles(code ?? "");
|
||||
|
||||
const [articleDraft, setArticleDraft] = useState<ArticleDraft | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContractTemplateArticle | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const sortedArticles = useMemo(
|
||||
() => [...(template?.articles ?? [])].sort((a, b) => a.order - b.order),
|
||||
[template],
|
||||
);
|
||||
|
||||
const moveArticle = (index: number, delta: -1 | 1) => {
|
||||
const next = [...sortedArticles];
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= next.length) return;
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
replaceArticles.mutate(
|
||||
next.map(({ id, title, body }) => ({ id, title, body })),
|
||||
);
|
||||
};
|
||||
|
||||
const saveArticle = () => {
|
||||
if (!articleDraft) return;
|
||||
if (articleDraft.id) {
|
||||
updateArticle.mutate({
|
||||
articleId: articleDraft.id,
|
||||
payload: { title: articleDraft.title, body: articleDraft.body },
|
||||
});
|
||||
} else {
|
||||
addArticle.mutate({ title: articleDraft.title, body: articleDraft.body });
|
||||
}
|
||||
setArticleDraft(null);
|
||||
};
|
||||
|
||||
if (isLoading || !template) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Center h={360}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={template.name}
|
||||
subtitle={template.documentTitle}
|
||||
backTo="/dashboard/contract-templates"
|
||||
meta={
|
||||
<Group gap={6}>
|
||||
<Badge variant="outline" color="edr-green">
|
||||
{template.code.replaceAll("_", " · ")}
|
||||
</Badge>
|
||||
{!template.isActive && (
|
||||
<Badge variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
<Switch
|
||||
color="edr-green"
|
||||
label="Active"
|
||||
checked={template.isActive}
|
||||
onChange={(event) =>
|
||||
updateTemplate.mutate({ isActive: event.currentTarget.checked })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Settings2 size={16} />}
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
>
|
||||
Document details
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setArticleDraft({ title: "", body: "" })}
|
||||
>
|
||||
Add article
|
||||
</Button>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{/* ── Article list ─────────────────────────────────────────────── */}
|
||||
<Stack gap="sm">
|
||||
{sortedArticles.map((article, index) => (
|
||||
<Card key={article.id} withBorder radius="lg" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" c="edr-green.7">
|
||||
Article {index + 1}
|
||||
</Text>
|
||||
<Title order={5}>{article.title}</Title>
|
||||
<Text size="sm" c="dimmed" lineClamp={2} mt={4}>
|
||||
{article.body}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Move up">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveArticle(index, -1)}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Move down">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
disabled={index === sortedArticles.length - 1}
|
||||
onClick={() => moveArticle(index, 1)}
|
||||
>
|
||||
<ArrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit article">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
onClick={() =>
|
||||
setArticleDraft({
|
||||
id: article.id,
|
||||
title: article.title,
|
||||
body: article.body,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove article">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => setDeleteTarget(article)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
{sortedArticles.length === 0 && (
|
||||
<Card withBorder radius="lg" padding="xl">
|
||||
<Center>
|
||||
<Text c="dimmed">
|
||||
No articles yet — add the first article to build this contract.
|
||||
</Text>
|
||||
</Center>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* ── Live preview ─────────────────────────────────────────────── */}
|
||||
<Card withBorder radius="lg" padding="sm" className="xl:sticky xl:top-4 self-start">
|
||||
<Group justify="space-between" mb="xs" px={4}>
|
||||
<Text fw={600} size="sm">
|
||||
Document preview (mock data)
|
||||
</Text>
|
||||
<Tooltip label="Refresh preview">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
loading={preview.isFetching}
|
||||
onClick={() => void preview.refetch()}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
|
||||
{preview.isLoading ? (
|
||||
<Center h={480}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : (
|
||||
<iframe
|
||||
title="Template preview"
|
||||
srcDoc={preview.data?.html}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "calc(100vh - 240px)",
|
||||
minHeight: 480,
|
||||
border: 0,
|
||||
background: "#f3f8f5",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Add / edit article modal ───────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(articleDraft)}
|
||||
onClose={() => setArticleDraft(null)}
|
||||
title={articleDraft?.id ? "Edit article" : "Add article"}
|
||||
size="xl"
|
||||
>
|
||||
{articleDraft && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Article title"
|
||||
placeholder="e.g. Obligations of the Client"
|
||||
value={articleDraft.title}
|
||||
onChange={(event) =>
|
||||
setArticleDraft({ ...articleDraft, title: event.currentTarget.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Article body"
|
||||
description={BODY_HINT}
|
||||
value={articleDraft.body}
|
||||
onChange={(event) =>
|
||||
setArticleDraft({ ...articleDraft, body: event.currentTarget.value })
|
||||
}
|
||||
autosize
|
||||
minRows={12}
|
||||
maxRows={24}
|
||||
styles={{ input: { fontFamily: "ui-monospace, monospace", fontSize: 13 } }}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setArticleDraft(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
disabled={
|
||||
articleDraft.title.trim().length < 2 ||
|
||||
articleDraft.body.trim().length < 2
|
||||
}
|
||||
loading={addArticle.isPending || updateArticle.isPending}
|
||||
onClick={saveArticle}
|
||||
>
|
||||
{articleDraft.id ? "Save changes" : "Add article"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Remove article"
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
Remove <strong>{deleteTarget?.title}</strong> from this template? The
|
||||
remaining articles are renumbered automatically.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={removeArticle.isPending}
|
||||
onClick={() => {
|
||||
if (deleteTarget) removeArticle.mutate(deleteTarget.id);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
Remove article
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* ── Document details modal ─────────────────────────────────────── */}
|
||||
<DocumentDetailsModal
|
||||
opened={detailsOpen}
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
initial={{
|
||||
name: template.name,
|
||||
description: template.description ?? "",
|
||||
documentTitle: template.documentTitle,
|
||||
whereasClauses: template.whereasClauses,
|
||||
}}
|
||||
saving={updateTemplate.isPending}
|
||||
onSave={(values) => {
|
||||
updateTemplate.mutate(values);
|
||||
setDetailsOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentDetailsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
initial: {
|
||||
name: string;
|
||||
description: string;
|
||||
documentTitle: string;
|
||||
whereasClauses: string[];
|
||||
};
|
||||
saving: boolean;
|
||||
onSave: (values: {
|
||||
name: string;
|
||||
description: string;
|
||||
documentTitle: string;
|
||||
whereasClauses: string[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
function DocumentDetailsModal({
|
||||
opened,
|
||||
onClose,
|
||||
initial,
|
||||
saving,
|
||||
onSave,
|
||||
}: DocumentDetailsModalProps) {
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [description, setDescription] = useState(initial.description);
|
||||
const [documentTitle, setDocumentTitle] = useState(initial.documentTitle);
|
||||
const [whereas, setWhereas] = useState(initial.whereasClauses.join("\n\n"));
|
||||
|
||||
// Re-sync local state each time the modal opens with fresh server data.
|
||||
const [lastOpened, setLastOpened] = useState(false);
|
||||
if (opened && !lastOpened) {
|
||||
setName(initial.name);
|
||||
setDescription(initial.description);
|
||||
setDocumentTitle(initial.documentTitle);
|
||||
setWhereas(initial.whereasClauses.join("\n\n"));
|
||||
setLastOpened(true);
|
||||
} else if (!opened && lastOpened) {
|
||||
setLastOpened(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Document details" size="xl">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Template name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="Cover page title"
|
||||
description="Printed on the contract cover, e.g. “Import Container Transport Service by Railway”."
|
||||
value={documentTitle}
|
||||
onChange={(event) => setDocumentTitle(event.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label="Card description"
|
||||
description="Shown on the Templates page card only — not printed."
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label="Recitals (WHEREAS clauses)"
|
||||
description="One recital per paragraph — separate recitals with a blank line."
|
||||
value={whereas}
|
||||
onChange={(event) => setWhereas(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={5}
|
||||
maxRows={12}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={saving}
|
||||
disabled={name.trim().length < 3 || documentTitle.trim().length < 3}
|
||||
onClick={() =>
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
documentTitle: documentTitle.trim(),
|
||||
whereasClauses: whereas
|
||||
.split(/\n\s*\n/)
|
||||
.map((clause) => clause.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
>
|
||||
Save details
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { Boxes, Container, Eye, FileSignature, Pencil } from "lucide-react";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { useContractTemplates } from "@/hooks/contract-templates/useContractTemplates";
|
||||
import type { ContractTemplate } from "@/services/contract-templates.service";
|
||||
import TemplatePreviewModal from "./TemplatePreviewModal";
|
||||
|
||||
const DIRECTION_LABEL: Record<string, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
INTERCITY: "Intercity",
|
||||
};
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "edr-green",
|
||||
EXPORT: "teal",
|
||||
INTERCITY: "lime",
|
||||
};
|
||||
|
||||
function templateDirection(code: ContractTemplate["code"]): string {
|
||||
return code.split("_")[0];
|
||||
}
|
||||
|
||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
}
|
||||
|
||||
export default function ContractTemplatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: templates, isLoading } = useContractTemplates();
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
const previewTemplate = templates?.find((t) => t.code === previewCode);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center h={320}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{(templates ?? []).map((template) => {
|
||||
const direction = templateDirection(template.code);
|
||||
return (
|
||||
<Card key={template.code} withBorder radius="xl" padding="lg">
|
||||
<Stack gap="sm" h="100%">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
{isBulk(template.code) ? (
|
||||
<Boxes size={24} />
|
||||
) : (
|
||||
<Container size={24} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<Group gap={6}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={DIRECTION_COLOR[direction] ?? "edr-green"}
|
||||
>
|
||||
{DIRECTION_LABEL[direction] ?? direction}
|
||||
</Badge>
|
||||
<Badge variant="outline" color="gray">
|
||||
{isBulk(template.code) ? "Bulk" : "Container"}
|
||||
</Badge>
|
||||
{!template.isActive && (
|
||||
<Badge variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<div>
|
||||
<Text fw={700} size="lg">
|
||||
{template.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" lineClamp={3}>
|
||||
{template.description || template.documentTitle}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group gap="xs" mt="auto">
|
||||
<FileSignature size={14} className="text-edr-primary" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{template.articles.length} articles · updated{" "}
|
||||
{new Date(template.updatedAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Eye size={16} />}
|
||||
onClick={() => setPreviewCode(template.code)}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Pencil size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-templates/${template.code}`)
|
||||
}
|
||||
>
|
||||
Edit articles
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<TemplatePreviewModal
|
||||
code={previewCode}
|
||||
title={previewTemplate ? `${previewTemplate.name} — preview` : undefined}
|
||||
onClose={() => setPreviewCode(null)}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Center, Loader, Modal, Paper, Text } from "@mantine/core";
|
||||
|
||||
import { useContractTemplatePreview } from "@/hooks/contract-templates/useContractTemplates";
|
||||
|
||||
interface TemplatePreviewModalProps {
|
||||
code: string | null;
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Full-document HTML preview rendered by the API against mock contract data. */
|
||||
export default function TemplatePreviewModal({
|
||||
code,
|
||||
title,
|
||||
onClose,
|
||||
}: TemplatePreviewModalProps) {
|
||||
const { data, isLoading, isError } = useContractTemplatePreview(
|
||||
code ?? undefined,
|
||||
Boolean(code),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={Boolean(code)}
|
||||
onClose={onClose}
|
||||
title={title ?? "Contract preview"}
|
||||
size="90%"
|
||||
padding="sm"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Center h={420}>
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError ? (
|
||||
<Center h={200}>
|
||||
<Text c="red">Failed to render the preview.</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Paper withBorder radius="md" style={{ overflow: "hidden" }}>
|
||||
<iframe
|
||||
title="Contract template preview"
|
||||
srcDoc={data?.html}
|
||||
style={{ width: "100%", height: "72vh", border: 0, background: "#f3f8f5" }}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -302,7 +302,7 @@ export default function ContractRequestDetailPage() {
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
: (contract.company?.name ?? "—");
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
|
||||
@@ -133,21 +133,21 @@ export default function ContractRequestsPage() {
|
||||
const columns: ColumnDef<ContractListRow>[] = [
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
header: () => <span className={bookingTable.headerCell}>Customer</span>,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className={bookingTable.rowIcon}>
|
||||
<FileText className="size-4" strokeWidth={1.75} />
|
||||
<User className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
{c.reference}
|
||||
{c.customerLabel}
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
|
||||
<User className="size-3 shrink-0 opacity-70" />
|
||||
{c.customerLabel}
|
||||
<FileText className="size-3 shrink-0 opacity-70" />
|
||||
{c.reference}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
@@ -16,10 +17,15 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDownWideNarrow,
|
||||
ArrowRight,
|
||||
ArrowUpNarrowWide,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Eye,
|
||||
@@ -47,9 +53,31 @@ import {
|
||||
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import type { BatchBoardSchedule } from "@/types/trainScheduling";
|
||||
import type {
|
||||
BatchBoardFilters,
|
||||
BatchBoardSchedule,
|
||||
BatchBoardSortField,
|
||||
TrainScheduleStatus,
|
||||
} from "@/types/trainScheduling";
|
||||
|
||||
const STATUS_BADGE: Record<string, { color: string; label: string }> = {
|
||||
DRAFT: { color: "gray", label: "Draft" },
|
||||
SCHEDULED: { color: "blue", label: "Scheduled" },
|
||||
DISPATCHED: { color: "orange", label: "Dispatched" },
|
||||
ARRIVED: { color: "green", label: "Arrived" },
|
||||
CANCELLED: { color: "red", label: "Cancelled" },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const meta = STATUS_BADGE[status] ?? { color: "gray", label: status };
|
||||
return (
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
const fmtTons = (n: number) =>
|
||||
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
|
||||
@@ -237,7 +265,8 @@ function ScheduleCard({ schedule }: { schedule: BatchBoardSchedule }) {
|
||||
c="dimmed"
|
||||
style={{ letterSpacing: 0.8 }}
|
||||
>
|
||||
Freight schedule · {schedule.status}
|
||||
{schedule.scheduleReference ?? "Freight schedule"} ·{" "}
|
||||
{schedule.status}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -395,15 +424,73 @@ function CardSkeleton() {
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery(
|
||||
api.trainScheduling.batchBoard.queryOptions({ refetchInterval: 30_000 }),
|
||||
);
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 12 });
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [windowFilter, setWindowFilter] = useState("ALL");
|
||||
const [sortBy, setSortBy] = useState<BatchBoardSortField>("createdAt");
|
||||
const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC");
|
||||
const [departureFrom, setDepartureFrom] = useState<Date | null>(null);
|
||||
const [departureTo, setDepartureTo] = useState<Date | null>(null);
|
||||
|
||||
const schedules = data ?? [];
|
||||
// Every knob maps straight onto the server-side batch-board query — the API
|
||||
// filters, searches, sorts and paginates; this page just renders the page.
|
||||
const filters = useMemo((): BatchBoardFilters => {
|
||||
const endOfDay = (d: Date) =>
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
statuses:
|
||||
statusFilter === "ALL"
|
||||
? undefined
|
||||
: [statusFilter as TrainScheduleStatus],
|
||||
bookingWindowStatus:
|
||||
windowFilter === "ALL"
|
||||
? undefined
|
||||
: (windowFilter as "OPEN" | "FULL" | "CLOSED"),
|
||||
departureFrom: departureFrom ? departureFrom.toISOString() : undefined,
|
||||
departureTo: departureTo ? endOfDay(departureTo).toISOString() : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
};
|
||||
}, [
|
||||
pagination,
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
]);
|
||||
|
||||
// Any filter change restarts from the first page.
|
||||
useEffect(() => {
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}, [
|
||||
debouncedSearch,
|
||||
statusFilter,
|
||||
windowFilter,
|
||||
departureFrom,
|
||||
departureTo,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setPagination,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
|
||||
refetchInterval: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const schedules = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const pageCount = data?.totalPages ?? 1;
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const openWindows = schedules.filter((s) => s.bookingWindowStatus === "OPEN").length;
|
||||
@@ -412,33 +499,6 @@ export default function BatchBoardPage() {
|
||||
return { openWindows, totalBookings, totalWagons };
|
||||
}, [schedules]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return schedules.filter((s) => {
|
||||
if (windowFilter !== "ALL" && s.bookingWindowStatus !== windowFilter) return false;
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
s.trainNumber,
|
||||
s.routeName,
|
||||
s.origin,
|
||||
s.destination,
|
||||
s.locomotive?.code,
|
||||
s.status,
|
||||
s.bookingWindowStatus,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [schedules, search, windowFilter]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||
const paged = useMemo(() => {
|
||||
const start = pagination.pageIndex * pagination.pageSize;
|
||||
return filtered.slice(start, start + pagination.pageSize);
|
||||
}, [filtered, pagination]);
|
||||
|
||||
const columns = useMemo((): ColumnDef<BatchBoardSchedule>[] => {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
@@ -456,6 +516,11 @@ export default function BatchBoardPage() {
|
||||
<Text size="sm" fw={700} lh={1.2} truncate>
|
||||
{row.original.trainNumber ?? row.original.routeName ?? "Schedule"}
|
||||
</Text>
|
||||
{row.original.scheduleReference ? (
|
||||
<Text size="10px" fw={600} c="dimmed" lh={1.2}>
|
||||
{row.original.scheduleReference}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box maw={220}>
|
||||
<RouteCorridor
|
||||
origin={row.original.origin}
|
||||
@@ -502,6 +567,30 @@ export default function BatchBoardPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Created",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const { day, time } = splitDate(row.original.createdAt);
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600} lh={1.2}>
|
||||
{day}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{time || "—"}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "window",
|
||||
header: "Window",
|
||||
@@ -623,7 +712,7 @@ export default function BatchBoardPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Batch Board"
|
||||
subtitle="Active schedules and their booking windows."
|
||||
subtitle="All import schedules — live and historical — with their booking windows."
|
||||
action={
|
||||
<Button variant="default" loading={isFetching} onClick={() => void refetch()}>
|
||||
Refresh
|
||||
@@ -635,21 +724,21 @@ export default function BatchBoardPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Active schedules",
|
||||
value: schedules.length,
|
||||
hint: "on the board right now",
|
||||
label: "Schedules",
|
||||
value: total,
|
||||
hint: "matching the current filters",
|
||||
icon: Train,
|
||||
},
|
||||
{
|
||||
label: "Open windows",
|
||||
value: summary.openWindows,
|
||||
hint: "accepting bookings",
|
||||
hint: "accepting bookings (this page)",
|
||||
icon: CalendarDays,
|
||||
},
|
||||
{
|
||||
label: "Bookings in play",
|
||||
value: summary.totalBookings,
|
||||
hint: `${summary.totalWagons} wagons allocated`,
|
||||
hint: `${summary.totalWagons} wagons allocated (this page)`,
|
||||
icon: Package,
|
||||
},
|
||||
]}
|
||||
@@ -661,24 +750,96 @@ export default function BatchBoardPage() {
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search schedules…"
|
||||
searchPlaceholder="Search train, route, station, loco…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={statusFilter}
|
||||
onChange={(v) => v && setStatusFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All statuses" },
|
||||
{ value: "DRAFT", label: "Draft" },
|
||||
{ value: "SCHEDULED", label: "Scheduled" },
|
||||
{ value: "DISPATCHED", label: "Dispatched" },
|
||||
{ value: "ARRIVED", label: "Arrived" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
]}
|
||||
w={150}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={windowFilter}
|
||||
onChange={(v) => v && setWindowFilter(v)}
|
||||
data={[
|
||||
{ value: "ALL", label: "All windows" },
|
||||
{ value: "OPEN", label: "Open" },
|
||||
{ value: "FULL", label: "Full" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
]}
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs from"
|
||||
value={departureFrom}
|
||||
onChange={(v) => setDepartureFrom(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<DateInput
|
||||
size="sm"
|
||||
radius="lg"
|
||||
placeholder="Departs to"
|
||||
value={departureTo}
|
||||
onChange={(v) => setDepartureTo(v ? new Date(v) : null)}
|
||||
clearable
|
||||
w={140}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
radius="lg"
|
||||
value={sortBy}
|
||||
onChange={(v) => v && setSortBy(v as BatchBoardSortField)}
|
||||
data={[
|
||||
{ value: "createdAt", label: "Sort: Created" },
|
||||
{ value: "scheduledDepartureDate", label: "Sort: Departure" },
|
||||
{ value: "trainNumber", label: "Sort: Train no." },
|
||||
{ value: "status", label: "Sort: Status" },
|
||||
]}
|
||||
w={160}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
label={sortOrder === "DESC" ? "Newest / Z–A first" : "Oldest / A–Z first"}
|
||||
withArrow
|
||||
>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={36}
|
||||
radius="lg"
|
||||
aria-label="Toggle sort direction"
|
||||
onClick={() =>
|
||||
setSortOrder((o) => (o === "DESC" ? "ASC" : "DESC"))
|
||||
}
|
||||
>
|
||||
{sortOrder === "DESC" ? (
|
||||
<ArrowDownWideNarrow size={16} />
|
||||
) : (
|
||||
<ArrowUpNarrowWide size={16} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
@@ -686,7 +847,7 @@ export default function BatchBoardPage() {
|
||||
{viewMode === "table" ? (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={paged}
|
||||
data={schedules}
|
||||
status={tableStatus}
|
||||
onRowClick={(schedule) =>
|
||||
navigate(`/dashboard/operations/batch-board/${schedule.scheduleId}`)
|
||||
@@ -699,12 +860,12 @@ export default function BatchBoardPage() {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No active schedules"
|
||||
emptyMessage="No schedules match the current filters"
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: filtered.length,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
manualPagination: true,
|
||||
@@ -727,7 +888,7 @@ export default function BatchBoardPage() {
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</SimpleGrid>
|
||||
) : filtered.length === 0 ? (
|
||||
) : schedules.length === 0 ? (
|
||||
<Paper radius="lg" p={48} m="md" bg="gray.0">
|
||||
<Stack align="center" gap="sm">
|
||||
<Box
|
||||
@@ -745,20 +906,52 @@ export default function BatchBoardPage() {
|
||||
<Inbox size={28} color="var(--mantine-color-gray-5)" />
|
||||
</Box>
|
||||
<Text fw={700} c="gray.7">
|
||||
No active schedules
|
||||
No schedules match the current filters
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={380}>
|
||||
Schedules with an open booking window appear here. Create or activate a
|
||||
schedule to get started.
|
||||
Every import schedule — live and historical — appears here. Loosen the
|
||||
filters or clear the search to see more.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{filtered.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<>
|
||||
<SimpleGrid cols={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="lg" p="md">
|
||||
{schedules.map((s) => (
|
||||
<ScheduleCard key={s.scheduleId} schedule={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Group justify="space-between" px="md" pb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} schedule{total === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex === 0}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex - 1 }))
|
||||
}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Text size="sm" c="dimmed">
|
||||
Page {pagination.pageIndex + 1} of {pageCount}
|
||||
</Text>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={pagination.pageIndex + 1 >= pageCount}
|
||||
onClick={() =>
|
||||
setPagination((p) => ({ ...p, pageIndex: p.pageIndex + 1 }))
|
||||
}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -20,12 +20,12 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ArrowLeftRight,
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hash,
|
||||
Hourglass,
|
||||
Layers,
|
||||
Package,
|
||||
@@ -622,6 +622,9 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Composition data only changes through mutations, which invalidate the
|
||||
// whole train-scheduling root — no need to refetch on remounts in between.
|
||||
staleTime: 5 * 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -745,7 +748,13 @@ export default function BatchScheduleDetailPage() {
|
||||
items={[
|
||||
{ label: "Operations" },
|
||||
{ label: "Batch board", href: "/dashboard/operations/batch-board" },
|
||||
{ label: data.trainNumber ?? data.routeName ?? "Schedule" },
|
||||
{
|
||||
label:
|
||||
data.scheduleReference ??
|
||||
data.trainNumber ??
|
||||
data.routeName ??
|
||||
"Schedule",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -792,6 +801,11 @@ export default function BatchScheduleDetailPage() {
|
||||
<Title order={2} fw={800}>
|
||||
{data.trainNumber ?? data.routeName ?? "Schedule"}
|
||||
</Title>
|
||||
{data.scheduleReference ? (
|
||||
<HeroChip icon={<Hash size={12} />}>
|
||||
{data.scheduleReference}
|
||||
</HeroChip>
|
||||
) : null}
|
||||
<WindowStatusPill status={data.bookingWindowStatus} />
|
||||
{data.windowPhase ? (
|
||||
<WindowPhasePill
|
||||
@@ -890,14 +904,6 @@ export default function BatchScheduleDetailPage() {
|
||||
|
||||
<KpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Allocated wagons",
|
||||
value: data.capacity.maxWagons
|
||||
? `${data.capacity.allocatedWagons} / ${data.capacity.maxWagons}`
|
||||
: data.capacity.allocatedWagons,
|
||||
hint: "on this train",
|
||||
icon: Boxes,
|
||||
},
|
||||
{
|
||||
label: "Train length",
|
||||
value: data.capacity.maxLengthMeters
|
||||
@@ -1111,7 +1117,7 @@ export default function BatchScheduleDetailPage() {
|
||||
<TrainCompositionDiagram
|
||||
locomotive={scheduleDetailQuery.data.trainSet?.locomotive}
|
||||
wagons={scheduleDetailQuery.data.trainSet?.wagons ?? []}
|
||||
freightType="CONTAINER"
|
||||
freightType={scheduleDetailQuery.data.freightType ?? null}
|
||||
trainNumber={scheduleDetailQuery.data.trainNumber}
|
||||
totalLengthMeters={
|
||||
scheduleDetailQuery.data.trainSet?.totalLengthMeters
|
||||
@@ -1133,7 +1139,7 @@ export default function BatchScheduleDetailPage() {
|
||||
<TrainConsistView
|
||||
scheduleDetail={scheduleDetailQuery.data}
|
||||
scheduleId={scheduleId ?? ""}
|
||||
maxWagons={53}
|
||||
maxWagons={data.capacity.maxWagons ?? 53}
|
||||
highlightBookingId={selectedBookingId}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -44,7 +44,8 @@ import {
|
||||
} from "@/types/rule-engine";
|
||||
import type {
|
||||
AssignBookingsPayload,
|
||||
BatchBoardSchedule,
|
||||
BatchBoardFilters,
|
||||
BatchBoardListResponse,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
@@ -223,11 +224,14 @@ export const api = {
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.schedules(),
|
||||
),
|
||||
|
||||
batchBoard: endpoint<void, BatchBoardSchedule[]>(
|
||||
batchBoard: endpoint<
|
||||
{ filters?: BatchBoardFilters },
|
||||
BatchBoardListResponse
|
||||
>(
|
||||
"train-scheduling",
|
||||
"batch-board",
|
||||
() => trainSchedulingService.getBatchBoard(),
|
||||
() => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
|
||||
({ filters }) => trainSchedulingService.getBatchBoard(filters),
|
||||
({ filters }) => QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(filters),
|
||||
),
|
||||
|
||||
allBookingWindows: endpoint<void, StaffBookingWindow[]>(
|
||||
|
||||
@@ -21,6 +21,19 @@ export interface BookingListFilter {
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
/** ISO date-time — bookings created on/after. */
|
||||
createdFrom?: string;
|
||||
/** ISO date-time — bookings created on/before (pass end-of-day for inclusive). */
|
||||
createdTo?: string;
|
||||
/** ISO date-time — bookings scheduled on/after. */
|
||||
scheduledFrom?: string;
|
||||
/** ISO date-time — bookings scheduled on/before (pass end-of-day for inclusive). */
|
||||
scheduledTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** "true" = government bookings only, "false" = private only. */
|
||||
isGovernment?: "true" | "false";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
@@ -130,6 +143,14 @@ export const bookingsService = {
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
}
|
||||
const response = await client.get<BookingListSummary>(B.LIST_SUMMARY, {
|
||||
params,
|
||||
@@ -154,6 +175,14 @@ export const bookingsService = {
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.paymentStatus) params.paymentStatus = filter.paymentStatus;
|
||||
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
|
||||
if (filter.createdTo) params.createdTo = filter.createdTo;
|
||||
if (filter.scheduledFrom) params.scheduledFrom = filter.scheduledFrom;
|
||||
if (filter.scheduledTo) params.scheduledTo = filter.scheduledTo;
|
||||
if (filter.originYardId) params.originYardId = filter.originYardId;
|
||||
if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
}
|
||||
const response = await client.get<PaginatedBookings>(B.BASE, {
|
||||
params,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
const BASE = "/contract-templates";
|
||||
|
||||
export interface ContractTemplateArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
code:
|
||||
| "IMPORT_BULK"
|
||||
| "EXPORT_BULK"
|
||||
| "INTERCITY_BULK"
|
||||
| "IMPORT_CONTAINER"
|
||||
| "EXPORT_CONTAINER"
|
||||
| "INTERCITY_CONTAINER";
|
||||
name: string;
|
||||
description?: string | null;
|
||||
documentTitle: string;
|
||||
whereasClauses: string[];
|
||||
articles: ContractTemplateArticle[];
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpdateContractTemplatePayload {
|
||||
name?: string;
|
||||
description?: string;
|
||||
documentTitle?: string;
|
||||
whereasClauses?: string[];
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ArticlePayload {
|
||||
title: string;
|
||||
body: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export const contractTemplatesService = {
|
||||
async list(): Promise<ContractTemplate[]> {
|
||||
const { data } = await client.get<ContractTemplate[]>(BASE);
|
||||
return data;
|
||||
},
|
||||
|
||||
async getByCode(code: string): Promise<ContractTemplate> {
|
||||
const { data } = await client.get<ContractTemplate>(`${BASE}/${code}`);
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(
|
||||
code: string,
|
||||
payload: UpdateContractTemplatePayload,
|
||||
): Promise<ContractTemplate> {
|
||||
const { data } = await client.patch<ContractTemplate>(
|
||||
`${BASE}/${code}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async preview(code: string): Promise<{ html: string }> {
|
||||
const { data } = await client.post<{ html: string }>(
|
||||
`${BASE}/${code}/preview`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async addArticle(code: string, payload: ArticlePayload): Promise<ContractTemplate> {
|
||||
const { data } = await client.post<ContractTemplate>(
|
||||
`${BASE}/${code}/articles`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateArticle(
|
||||
code: string,
|
||||
articleId: string,
|
||||
payload: Partial<ArticlePayload>,
|
||||
): Promise<ContractTemplate> {
|
||||
const { data } = await client.patch<ContractTemplate>(
|
||||
`${BASE}/${code}/articles/${articleId}`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async removeArticle(code: string, articleId: string): Promise<ContractTemplate> {
|
||||
const { data } = await client.delete<ContractTemplate>(
|
||||
`${BASE}/${code}/articles/${articleId}`,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
async replaceArticles(
|
||||
code: string,
|
||||
articles: Array<{ id?: string; title: string; body: string }>,
|
||||
): Promise<ContractTemplate> {
|
||||
const { data } = await client.put<ContractTemplate>(
|
||||
`${BASE}/${code}/articles`,
|
||||
{ articles },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -3,7 +3,8 @@ import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
BatchBoardSchedule,
|
||||
BatchBoardFilters,
|
||||
BatchBoardListResponse,
|
||||
BatchBoardScheduleDetail,
|
||||
BookableSchedule,
|
||||
BookingWindow,
|
||||
@@ -100,9 +101,25 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getBatchBoard: async (): Promise<BatchBoardSchedule[]> => {
|
||||
const response = await client.get<BatchBoardSchedule[]>(
|
||||
getBatchBoard: async (
|
||||
filters: BatchBoardFilters = {},
|
||||
): Promise<BatchBoardListResponse> => {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (filters.page) params.page = filters.page;
|
||||
if (filters.pageSize) params.pageSize = filters.pageSize;
|
||||
if (filters.statuses?.length) params.statuses = filters.statuses.join(",");
|
||||
if (filters.bookingWindowStatus)
|
||||
params.bookingWindowStatus = filters.bookingWindowStatus;
|
||||
if (filters.search?.trim()) params.search = filters.search.trim();
|
||||
if (filters.departureFrom) params.departureFrom = filters.departureFrom;
|
||||
if (filters.departureTo) params.departureTo = filters.departureTo;
|
||||
if (filters.createdFrom) params.createdFrom = filters.createdFrom;
|
||||
if (filters.createdTo) params.createdTo = filters.createdTo;
|
||||
if (filters.sortBy) params.sortBy = filters.sortBy;
|
||||
if (filters.sortOrder) params.sortOrder = filters.sortOrder;
|
||||
const response = await client.get<BatchBoardListResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.BATCH_BOARD,
|
||||
{ params },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
@@ -257,11 +257,14 @@ export interface StaffBookingWindow {
|
||||
|
||||
export interface BatchBoardSchedule {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
scheduleDate: string | null;
|
||||
createdAt: string | null;
|
||||
status: string;
|
||||
bookingWindowStatus: string;
|
||||
direction: string | null;
|
||||
@@ -300,6 +303,37 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
export type BatchBoardSortField =
|
||||
| "createdAt"
|
||||
| "scheduledDepartureDate"
|
||||
| "trainNumber"
|
||||
| "status";
|
||||
|
||||
/** Server-side filters for the paginated batch board list. */
|
||||
export interface BatchBoardFilters {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** Subset of schedule statuses; omit for all (incl. arrived/cancelled). */
|
||||
statuses?: TrainScheduleStatus[];
|
||||
bookingWindowStatus?: "OPEN" | "FULL" | "CLOSED";
|
||||
/** Matches train number, route yards, stations, locomotive code. */
|
||||
search?: string;
|
||||
departureFrom?: string;
|
||||
departureTo?: string;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
sortBy?: BatchBoardSortField;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface BatchBoardListResponse {
|
||||
items: BatchBoardSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type BookingAllocationStatus =
|
||||
| "NOT_ATTEMPTED"
|
||||
| "ASSIGNED"
|
||||
@@ -337,6 +371,8 @@ export interface BatchWindowGroup {
|
||||
|
||||
export interface BatchBoardScheduleDetail {
|
||||
scheduleId: string;
|
||||
/** Human-facing schedule reference (S-YYYY-NNNNN). */
|
||||
scheduleReference: string | null;
|
||||
trainNumber: string | null;
|
||||
routeName: string | null;
|
||||
origin: string | null;
|
||||
@@ -506,6 +542,8 @@ export interface TrainScheduleDetail {
|
||||
capacityTons: number;
|
||||
lengthMeters: number;
|
||||
assignedWeightTons: number;
|
||||
/** Empty-wagon weight from the wagon type — gross = tare + cargo. */
|
||||
tareWeightTons?: number | null;
|
||||
status?: string;
|
||||
physicalWagonId?: string | null;
|
||||
physicalWagonNumber?: string | null;
|
||||
|
||||
@@ -86,8 +86,9 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
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;
|
||||
// once inspection passes. An import item parked back into storage returns
|
||||
// to pickup — otherwise Store would strand it with no action.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
@@ -367,6 +368,8 @@ export interface ReleaseOrderPayload {
|
||||
grossWeight?: number;
|
||||
netWeight?: number;
|
||||
gateOutTime?: string;
|
||||
/** Container bookings only: operator chose not to weigh — tare/gross omitted, match skipped. */
|
||||
weighingSkipped?: boolean;
|
||||
}
|
||||
|
||||
/** Import branch: proof of delivery captured on customer pickup. */
|
||||
@@ -385,6 +388,8 @@ export interface EligibleBooking {
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
/** Distinct seal numbers from the booking's container units, comma-joined. */
|
||||
sealNumbers: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
|
||||
Reference in New Issue
Block a user