mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
@@ -36,31 +35,19 @@ import {
|
||||
BookingCargoCard,
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingDocumentsCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
ContractOrdersPanel,
|
||||
type BookingFileView,
|
||||
} from "@/components/bookings/detail";
|
||||
import { WarehouseInfoCard } from "@/components/warehouses";
|
||||
import { getStatusMeta } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingDetail,
|
||||
useBookingMutations,
|
||||
} from "@/hooks/bookings/useBookings";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Signature / generated-contract files are surfaced on the contract page, not
|
||||
// in the booking's Documents list.
|
||||
const SIGNATURE_FILE_CODES = new Set([
|
||||
"signature",
|
||||
"signature_customer",
|
||||
"signature_staff",
|
||||
"contract",
|
||||
]);
|
||||
|
||||
export default function BookingRequestDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -77,14 +64,6 @@ export default function BookingRequestDetailPage() {
|
||||
} = useBookingDetail(id);
|
||||
const mutations = useBookingMutations(id ?? "");
|
||||
|
||||
const handleDownloadFile = async (file: BookingFileView) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -149,11 +128,6 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
const showContractButton = [
|
||||
"CONTRACT_READY",
|
||||
"SIGNED_CUSTOMER",
|
||||
"FULLY_EXECUTED",
|
||||
].includes(booking.status);
|
||||
const showApprovalCard =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE";
|
||||
@@ -246,11 +220,7 @@ export default function BookingRequestDetailPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
</Tabs.Panel>
|
||||
{isGeneralContract && (
|
||||
<Tabs.Panel value="orders">
|
||||
@@ -270,11 +240,7 @@ export default function BookingRequestDetailPage() {
|
||||
)}
|
||||
</Tabs>
|
||||
) : (
|
||||
<OverviewPanel
|
||||
booking={booking}
|
||||
row={row}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<OverviewPanel booking={booking} row={row} />
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
@@ -306,20 +272,6 @@ export default function BookingRequestDetailPage() {
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/booking-requests/${booking.id}/contract`,
|
||||
)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{showApprovalCard && (
|
||||
<ApprovalStepsCard booking={booking} mutations={mutations} />
|
||||
)}
|
||||
@@ -332,15 +284,13 @@ export default function BookingRequestDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The booking's primary detail cards — route, services, cargo, contract, docs. */
|
||||
/** The booking's primary detail cards — route, services, cargo, containers. */
|
||||
function OverviewPanel({
|
||||
booking,
|
||||
row,
|
||||
onDownload,
|
||||
}: {
|
||||
booking: BookingDetail;
|
||||
row: ReturnType<typeof toBookingListRow>;
|
||||
onDownload: (file: BookingFileView) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
@@ -351,15 +301,10 @@ function OverviewPanel({
|
||||
/>
|
||||
<BookingMileServicesCard booking={booking} />
|
||||
<BookingCargoCard booking={booking} />
|
||||
<BookingContainerUnitsCard booking={booking} />
|
||||
{booking.contractSummary && (
|
||||
<BookingContractSummaryCard summary={booking.contractSummary} />
|
||||
)}
|
||||
<BookingDocumentsCard
|
||||
files={(booking.files ?? []).filter(
|
||||
(f) => !SIGNATURE_FILE_CODES.has(f.code ?? ""),
|
||||
)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -30,17 +31,12 @@ import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import {
|
||||
BookingStatusTabs,
|
||||
type BookingStatusTabKey,
|
||||
} from "@/components/bookings/BookingStatusTabs";
|
||||
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
|
||||
import { BookingTableEmpty } from "@/components/bookings/BookingTableEmpty";
|
||||
import { OperationsBookingQueue } from "@/components/bookings/OperationsBookingQueue";
|
||||
import { OperationsScheduledBookings } from "@/components/bookings/OperationsScheduledBookings";
|
||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import {
|
||||
useBookingDetail,
|
||||
@@ -57,11 +53,29 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
function getStatusesForTab(tab: BookingStatusTabKey): string | undefined {
|
||||
const match = BOOKING_LIST_TABS.find((t) => t.key === tab);
|
||||
if (!match?.statuses?.length) return undefined;
|
||||
return match.statuses.join(",");
|
||||
}
|
||||
/** The two booking-kind tabs: one-time vs general-contract bookings. */
|
||||
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
|
||||
|
||||
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
|
||||
{ value: "ONE_TIME", label: "One-time booking" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General booking" },
|
||||
];
|
||||
|
||||
/** Status options for the filter select — built from the shared status styles. */
|
||||
const STATUS_OPTIONS = Object.entries(BOOKING_STATUS_STYLES).map(
|
||||
([value, { label }]) => ({ value, label }),
|
||||
);
|
||||
|
||||
const TRADE_DIRECTION_OPTIONS = [
|
||||
{ value: "IMPORT", label: "Import" },
|
||||
{ value: "EXPORT", label: "Export" },
|
||||
{ value: "DOMESTIC", label: "Domestic" },
|
||||
];
|
||||
|
||||
const FREIGHT_TYPE_OPTIONS = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
@@ -75,14 +89,16 @@ function formatDate(value: string | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
type OperationsSubTab = "ready" | "scheduled";
|
||||
|
||||
export default function BookingRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<BookingStatusTabKey>("all");
|
||||
const [operationsSubTab, setOperationsSubTab] = useState<OperationsSubTab>("ready");
|
||||
// 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);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const [allocateIds, setAllocateIds] = useState<string[]>([]);
|
||||
const suppressRowClickRef = useRef(false);
|
||||
@@ -93,47 +109,26 @@ export default function BookingRequestsPage() {
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const tabStatuses = getStatusesForTab(activeTab);
|
||||
const isOperationsTab = activeTab === "operations";
|
||||
|
||||
const filter: BookingListFilter = useMemo(() => {
|
||||
if (isOperationsTab) {
|
||||
if (operationsSubTab === "ready") {
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
assignedToSchedule: "false",
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
};
|
||||
}
|
||||
return {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
statuses: "PAID",
|
||||
schedulingStatuses: "SCHEDULED,DISPATCHED",
|
||||
sortBy: "scheduledDate",
|
||||
sortOrder: "ASC",
|
||||
tab: activeTab,
|
||||
};
|
||||
}
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "DESC",
|
||||
tab: activeTab,
|
||||
...(tabStatuses ? { statuses: tabStatuses } : {}),
|
||||
// React Query cache key per kind tab.
|
||||
tab: kindTab,
|
||||
bookingType: kindTab,
|
||||
...(statusFilter ? { statuses: statusFilter } : {}),
|
||||
...(directionFilter ? { tradeDirection: directionFilter } : {}),
|
||||
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
|
||||
};
|
||||
}, [
|
||||
isOperationsTab,
|
||||
operationsSubTab,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
activeTab,
|
||||
tabStatuses,
|
||||
kindTab,
|
||||
statusFilter,
|
||||
directionFilter,
|
||||
freightTypeFilter,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError, refetch, isFetching } = useBookingList(filter);
|
||||
@@ -171,18 +166,6 @@ export default function BookingRequestsPage() {
|
||||
void refetchSummary();
|
||||
}, [refetch, refetchSummary]);
|
||||
|
||||
const handleAllocateFromQueue = useCallback(
|
||||
(ids: string[]) => {
|
||||
const selected = rows.filter((b) => ids.includes(b.id));
|
||||
const sorted = [...selected].sort(
|
||||
(a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0),
|
||||
);
|
||||
setAllocateIds(sorted.map((b) => b.id));
|
||||
setAllocateOpen(true);
|
||||
},
|
||||
[rows],
|
||||
);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: BookingListRow) => {
|
||||
if (suppressRowClickRef.current) return;
|
||||
@@ -356,6 +339,8 @@ export default function BookingRequestsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Status tabs replaced by booking-kind tabs (one-time / general). The
|
||||
old BookingStatusTabs is commented out — status is now a filter select.
|
||||
<BookingStatusTabs
|
||||
active={activeTab}
|
||||
onChange={(tab) => {
|
||||
@@ -364,73 +349,97 @@ export default function BookingRequestsPage() {
|
||||
}}
|
||||
counts={tabCounts}
|
||||
/>
|
||||
*/}
|
||||
|
||||
<Tabs
|
||||
value={kindTab}
|
||||
onChange={(value) => {
|
||||
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
>
|
||||
<Tabs.List>
|
||||
{BOOKING_KIND_TABS.map((t) => (
|
||||
<Tabs.Tab key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
placeholder="Search reference or customer…"
|
||||
leftSection={<Search size={18} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
rightSection={
|
||||
query && (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
color="gray"
|
||||
radius="md"
|
||||
variant="transparent"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={16} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
style={{ flex: 1, minWidth: "200px" }}
|
||||
radius="lg"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{total} record{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="All statuses"
|
||||
data={STATUS_OPTIONS}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
searchable
|
||||
radius="lg"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="All freight types"
|
||||
data={FREIGHT_TYPE_OPTIONS}
|
||||
value={freightTypeFilter}
|
||||
onChange={(v) => {
|
||||
setFreightTypeFilter(v);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
clearable
|
||||
radius="lg"
|
||||
style={{ minWidth: 170 }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{isOperationsTab ? (
|
||||
<Box px="md" pb="md">
|
||||
<Stack gap="md">
|
||||
<Tabs
|
||||
value={operationsSubTab}
|
||||
onChange={(value) =>
|
||||
setOperationsSubTab((value as OperationsSubTab) ?? "ready")
|
||||
}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="ready">Ready to allocate</Tabs.Tab>
|
||||
<Tabs.Tab value="scheduled">On train / scheduled</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
{isError ? (
|
||||
<BookingTableEmpty
|
||||
isError
|
||||
hasSearch={false}
|
||||
onRetry={handleRefresh}
|
||||
/>
|
||||
) : operationsSubTab === "ready" ? (
|
||||
<OperationsBookingQueue
|
||||
bookings={rows}
|
||||
isLoading={isLoading}
|
||||
onAllocate={handleAllocateFromQueue}
|
||||
/>
|
||||
) : (
|
||||
<OperationsScheduledBookings
|
||||
bookings={rows}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
) : showEmpty ? (
|
||||
{showEmpty ? (
|
||||
<Box px="md" pb="md">
|
||||
<BookingTableEmpty
|
||||
isError={isError}
|
||||
|
||||
@@ -61,9 +61,11 @@ import {
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
@@ -152,6 +154,34 @@ export default function ContractRequestDetailPage() {
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
// Customer profile documents (national ID, TIN, import/business license) for
|
||||
// the company this contract belongs to. Shown as a separate section in the
|
||||
// Documents tab, alongside the contract's own attached files.
|
||||
const companyId = contract?.companyId ?? "";
|
||||
const profileDocumentsQuery = useQuery(
|
||||
api.customers.documents.queryOptions({
|
||||
input: { id: companyId },
|
||||
enabled: Boolean(companyId),
|
||||
}),
|
||||
);
|
||||
const profileDocumentsRaw = Array.isArray(profileDocumentsQuery.data)
|
||||
? profileDocumentsQuery.data
|
||||
: [];
|
||||
// Reshape to the contract-file shape so we can reuse ContractDocumentsCard.
|
||||
const profileDocuments = profileDocumentsRaw.map(
|
||||
(doc: CustomerDocument) =>
|
||||
({
|
||||
id: doc.id,
|
||||
code: doc.code,
|
||||
name: doc.name,
|
||||
url: doc.url ?? "",
|
||||
mimeType: doc.mimeType,
|
||||
size: doc.size,
|
||||
resourceId: companyId,
|
||||
resource: "company",
|
||||
}) satisfies NonNullable<Freight.IContract["files"]>[number],
|
||||
);
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
@@ -247,6 +277,11 @@ export default function ContractRequestDetailPage() {
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
||||
// don't list them as standalone documents in the Documents tab.
|
||||
const contractDocuments = files.filter(
|
||||
(f) => !f.code.startsWith("signature_"),
|
||||
);
|
||||
const hasContractDocument = Boolean(
|
||||
contractPdf || contract.contractGeneratedAt,
|
||||
);
|
||||
@@ -406,9 +441,9 @@ export default function ContractRequestDetailPage() {
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
contractDocuments.length + profileDocuments.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
{contractDocuments.length + profileDocuments.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
@@ -453,7 +488,18 @@ export default function ContractRequestDetailPage() {
|
||||
) : currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
files={contractDocuments}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<ContractDocumentsCard
|
||||
files={profileDocuments}
|
||||
title="Customer profile documents"
|
||||
emptyText={
|
||||
profileDocumentsQuery.isLoading
|
||||
? "Loading customer documents…"
|
||||
: "No profile documents on file for this customer."
|
||||
}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
|
||||
@@ -135,9 +135,11 @@ export default function CustomerDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const bookings = bookingsQuery.data ?? [];
|
||||
const documents = documentsQuery.data ?? [];
|
||||
const payments = paymentsQuery.data ?? [];
|
||||
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
|
||||
const documents = Array.isArray(documentsQuery.data)
|
||||
? documentsQuery.data
|
||||
: [];
|
||||
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
|
||||
const invoices = invoicesQuery.data?.items ?? [];
|
||||
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
||||
const invoicePageCount = Math.max(
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -25,10 +26,12 @@ import {
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
PackageCheck,
|
||||
Route as RouteIcon,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
Workflow as WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -46,6 +49,7 @@ import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/Imp
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import {
|
||||
RouteCorridor,
|
||||
@@ -1053,6 +1057,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
Workflow
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="workspace" leftSection={<PackageCheck size={16} />}>
|
||||
Workspace
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
<Stack gap="lg">
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
@@ -1110,7 +1126,20 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="workspace">
|
||||
<ScheduleWorkspacePanel
|
||||
schedule={schedule}
|
||||
onChanged={() => {
|
||||
autoPreviewedRef.current = false;
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
{scheduleId ? (
|
||||
<RescheduleTrainDialog
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { Button, Card, Group, NumberInput, Stack } from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainSchedulingGlobalRules } from "@/types/trainScheduling";
|
||||
@@ -19,32 +20,66 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const rules = await trainSchedulingService.getGlobalRules();
|
||||
setForm(rules);
|
||||
// `numeric` columns come back from the API as strings (e.g. "250.00").
|
||||
// Coerce every field to a real number so Mantine's controlled
|
||||
// NumberInput edits cleanly (a string value fights the caret) and the
|
||||
// default can be cleared and replaced.
|
||||
const numeric: Partial<Record<keyof TrainSchedulingGlobalRules, number | string>> = {};
|
||||
for (const [key, value] of Object.entries(rules)) {
|
||||
if (key === "id") continue;
|
||||
const num = value === "" || value == null ? "" : Number(value);
|
||||
numeric[key as keyof TrainSchedulingGlobalRules] =
|
||||
typeof num === "number" && Number.isNaN(num) ? "" : num;
|
||||
}
|
||||
setForm(numeric);
|
||||
} catch {
|
||||
toast({ title: "Failed to load train scheduling rules", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [toast]);
|
||||
// Run once on mount only. `toast` from useToast is a fresh function every
|
||||
// render — listing it here re-fired the effect on every render, refetching
|
||||
// the rules and overwriting whatever the user was typing (values snapped
|
||||
// back to the saved defaults).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
// Every field must hold a real number — an empty box (cleared but not
|
||||
// refilled) must not silently save as 0. Collect the numeric payload and
|
||||
// reject if any value is blank or NaN.
|
||||
const fields: (keyof TrainSchedulingGlobalRules)[] = [
|
||||
"maxTrainLengthMeters",
|
||||
"maxTrainWeightTons",
|
||||
"maxWagonsPerTrain",
|
||||
"max20ftContainerWeightTons",
|
||||
"max20ftPairWeightDiffTons",
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
"windowDurationHours",
|
||||
"docReviewMinutes",
|
||||
"paymentWindowMinutes",
|
||||
"reopenDelayMinutes",
|
||||
];
|
||||
const payload: Partial<Record<keyof TrainSchedulingGlobalRules, number>> = {};
|
||||
for (const key of fields) {
|
||||
const raw = form[key];
|
||||
const num = raw === "" || raw == null ? NaN : Number(raw);
|
||||
if (!Number.isFinite(num)) {
|
||||
toast({
|
||||
title: "All fields are required — fill every value before saving.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
payload[key] = num;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await trainSchedulingService.updateGlobalRules({
|
||||
maxTrainLengthMeters: Number(form.maxTrainLengthMeters),
|
||||
maxTrainWeightTons: Number(form.maxTrainWeightTons),
|
||||
maxWagonsPerTrain: Number(form.maxWagonsPerTrain),
|
||||
max20ftContainerWeightTons: Number(form.max20ftContainerWeightTons),
|
||||
max20ftPairWeightDiffTons: Number(form.max20ftPairWeightDiffTons),
|
||||
importWindowLeadDays: Number(form.importWindowLeadDays),
|
||||
exportBookingLeadHours: Number(form.exportBookingLeadHours),
|
||||
windowOpenHour: Number(form.windowOpenHour),
|
||||
windowDurationHours: Number(form.windowDurationHours),
|
||||
docReviewMinutes: Number(form.docReviewMinutes),
|
||||
paymentWindowMinutes: Number(form.paymentWindowMinutes),
|
||||
reopenDelayMinutes: Number(form.reopenDelayMinutes),
|
||||
});
|
||||
const updated = await trainSchedulingService.updateGlobalRules(payload);
|
||||
setForm(updated);
|
||||
toast({ title: "Train scheduling rules saved" });
|
||||
} catch {
|
||||
@@ -70,6 +105,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -80,6 +117,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -89,6 +128,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, maxWagonsPerTrain: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -102,6 +143,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftContainerWeightTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0.001}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -115,6 +158,8 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max20ftPairWeightDiffTons: value,
|
||||
}))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
@@ -127,20 +172,22 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
title="Booking windows"
|
||||
subtitle="Import booking-day cycle and export lead time. All times in Addis Ababa (EAT)."
|
||||
/>
|
||||
<NumberInput
|
||||
label="Import window lead (days)"
|
||||
description="The single booking day opens this many days before departure"
|
||||
<DurationField
|
||||
label="Import window lead"
|
||||
description="The single booking day opens this long before departure"
|
||||
value={form.importWindowLeadDays ?? ""}
|
||||
nativeUnit="days"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, importWindowLeadDays: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Export booking lead (hours)"
|
||||
description="Export bookings are accepted first-come-first-serve starting this many hours before departure"
|
||||
<DurationField
|
||||
label="Export booking lead"
|
||||
description="Export bookings are accepted first-come-first-serve starting this long before departure"
|
||||
value={form.exportBookingLeadHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, exportBookingLeadHours: value }))
|
||||
}
|
||||
@@ -154,45 +201,50 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window duration (hours)"
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long the import booking window stays open"
|
||||
value={form.windowDurationHours ?? ""}
|
||||
nativeUnit="hours"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowDurationHours: value }))
|
||||
}
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Document review (minutes)"
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Max staff time to accept booking documents after the window closes"
|
||||
value={form.docReviewMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, docReviewMinutes: value }))
|
||||
}
|
||||
min={0}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Payment window (minutes)"
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay before the slot expires"
|
||||
value={form.paymentWindowMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, paymentWindowMinutes: value }))
|
||||
}
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Reopen delay (minutes)"
|
||||
description="Delay after window close before reopening when the train is not full (90 = 11:00 close → 12:30 reopen)"
|
||||
<DurationField
|
||||
label="Reopen delay"
|
||||
description="Delay after window close before reopening when the train is not full (90 min = 11:00 close → 12:30 reopen)"
|
||||
value={form.reopenDelayMinutes ?? ""}
|
||||
nativeUnit="minutes"
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, reopenDelayMinutes: value }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user