mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
feat: add pagination to schedule history and consolidation approvals
- Implemented pagination in ScheduleHistoryPanel to manage large history entries. - Updated API to support pagination parameters for schedule history. - Enhanced ConsolidationApprovalsPage with tabbed navigation and pagination for approval rows. - Introduced new types for paginated responses in bookings and train scheduling services. - Added a database migration to create an index on wagon_booking_allocations for performance improvements.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Badge,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
Timeline,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
History,
|
||||
@@ -41,13 +43,18 @@ const ACTION_META: Record<
|
||||
* bookings removed from the composition — newest first.
|
||||
*/
|
||||
export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: string }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const historyQuery = useQuery(
|
||||
api.trainScheduling.scheduleHistory.queryOptions({
|
||||
input: { scheduleId },
|
||||
input: { scheduleId, page, pageSize: 20 },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Keep the previous page on screen while the next one loads.
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
);
|
||||
const entries = historyQuery.data ?? [];
|
||||
const entries = historyQuery.data?.items ?? [];
|
||||
const totalPages = Math.max(1, historyQuery.data?.meta.totalPages ?? 1);
|
||||
const total = historyQuery.data?.meta.total ?? 0;
|
||||
|
||||
return (
|
||||
<Paper radius="xl" p="lg">
|
||||
@@ -131,6 +138,15 @@ export default function ScheduleHistoryPanel({ scheduleId }: { scheduleId: strin
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{total} change(s)
|
||||
</Text>
|
||||
<Pagination size="sm" value={page} onChange={setPage} total={totalPages} />
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -10,13 +10,15 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Pagination,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, Check, Clock, Link2, X } from "lucide-react";
|
||||
import { AlertCircle, Check, Clock, Link2, User, X } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -28,6 +30,39 @@ import { formatDateTime } from "@/lib/format";
|
||||
import { extractErrorMessage } from "@/utils/errorExtractor";
|
||||
|
||||
const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
type Status = ConsolidationApprovalRow["status"];
|
||||
|
||||
const TABS: { value: Status; label: string }[] = [
|
||||
{ value: "PENDING", label: "Awaiting approval" },
|
||||
{ value: "APPROVED", label: "Approved" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
];
|
||||
|
||||
const STATUS_COLOR: Record<Status, string> = {
|
||||
PENDING: "yellow",
|
||||
APPROVED: "green",
|
||||
REJECTED: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
PENDING: "Awaiting approval",
|
||||
APPROVED: "Approved",
|
||||
REJECTED: "Rejected",
|
||||
};
|
||||
|
||||
const STATUS_VERB: Record<Status, string> = {
|
||||
PENDING: "",
|
||||
APPROVED: "Approved by",
|
||||
REJECTED: "Rejected by",
|
||||
};
|
||||
|
||||
const EMPTY_TEXT: Record<Status, string> = {
|
||||
PENDING: "Nothing waiting for approval.",
|
||||
APPROVED: "No shared wagon has been approved yet.",
|
||||
REJECTED: "No shared wagon has been rejected.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Review queue for shared-wagon pairings.
|
||||
@@ -37,6 +72,11 @@ const QUEUE_KEY = ["consolidation-approvals", "queue"];
|
||||
* under two separate invoices, so a person signs off on the pairing first.
|
||||
* Approving releases BOTH bookings to Operations; rejecting sends BOTH back to
|
||||
* GL with the reason.
|
||||
*
|
||||
* Decided pairings stay on the page rather than vanishing: the decided tabs are
|
||||
* the record of who signed off on which wagon and why. A rejection is not final
|
||||
* either — a rejected pairing can still be approved from here once whatever
|
||||
* blocked it is settled.
|
||||
*/
|
||||
export default function ConsolidationApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
@@ -45,16 +85,31 @@ export default function ConsolidationApprovalsPage() {
|
||||
kind: "approve" | "reject";
|
||||
} | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [tab, setTab] = useState<Status>("PENDING");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const {
|
||||
data: rows,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: QUEUE_KEY,
|
||||
queryFn: () => bookingsService.consolidationApprovalQueue(),
|
||||
const { data, isLoading, isError, isFetching } = useQuery({
|
||||
queryKey: [...QUEUE_KEY, tab, page],
|
||||
queryFn: () =>
|
||||
bookingsService.consolidationApprovalQueue({
|
||||
status: tab,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
}),
|
||||
// Keeping the last page on screen while the next one loads stops the list
|
||||
// from collapsing to a spinner on every page or tab click.
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
|
||||
const shown = data?.items ?? [];
|
||||
const pageCount = Math.max(1, data?.meta.totalPages ?? 1);
|
||||
const countOf = (status: Status) => data?.counts?.[status] ?? 0;
|
||||
|
||||
const goToTab = (next: Status) => {
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setDecision(null);
|
||||
setNote("");
|
||||
@@ -64,7 +119,10 @@ export default function ConsolidationApprovalsPage() {
|
||||
mutationFn: () => {
|
||||
if (!decision) throw new Error("No pairing selected");
|
||||
return decision.kind === "approve"
|
||||
? bookingsService.approveConsolidation(decision.row.id, note.trim() || undefined)
|
||||
? bookingsService.approveConsolidation(
|
||||
decision.row.id,
|
||||
note.trim() || undefined,
|
||||
)
|
||||
: bookingsService.rejectConsolidation(decision.row.id, note.trim());
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -73,6 +131,7 @@ export default function ConsolidationApprovalsPage() {
|
||||
? "Shared wagon approved — both bookings sent to Operations"
|
||||
: "Shared wagon rejected — both bookings returned to GL",
|
||||
);
|
||||
goToTab(decision?.kind === "approve" ? "APPROVED" : "REJECTED");
|
||||
void qc.invalidateQueries({ queryKey: QUEUE_KEY });
|
||||
close();
|
||||
},
|
||||
@@ -99,90 +158,190 @@ export default function ConsolidationApprovalsPage() {
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||
Could not load the approval queue.
|
||||
</Alert>
|
||||
) : !rows?.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
Nothing waiting for approval.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{rows.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon variant="light" color="blue" radius="md" size={30}>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light" radius="sm">
|
||||
Awaiting approval
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={row.booking?.reference ?? row.bookingReference}
|
||||
company={row.booking?.company?.name}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => goToTab((value as Status) ?? "PENDING")}
|
||||
radius="md"
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
{TABS.map(({ value, label }) => (
|
||||
<Tabs.Tab
|
||||
key={value}
|
||||
value={value}
|
||||
rightSection={
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
color={STATUS_COLOR[value]}
|
||||
radius="sm"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
{countOf(value)}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{!shown.length ? (
|
||||
<Alert color="gray" radius="md" icon={<Check size={16} />}>
|
||||
{EMPTY_TEXT[tab]}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{shown.map((row) => (
|
||||
<Paper
|
||||
key={row.id}
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: "#E6ECF2" }}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Group gap={8} align="center" mb={10}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="md"
|
||||
size={30}
|
||||
>
|
||||
<Link2 size={16} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={15}>
|
||||
Shared wagon
|
||||
</Text>
|
||||
<Badge
|
||||
color={STATUS_COLOR[row.status]}
|
||||
variant="light"
|
||||
radius="sm"
|
||||
>
|
||||
{STATUS_LABEL[row.status]}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Group gap="xl" wrap="wrap">
|
||||
<BookingSide
|
||||
id={row.bookingId}
|
||||
reference={
|
||||
row.booking?.reference ?? row.bookingReference
|
||||
}
|
||||
company={row.booking?.company?.name}
|
||||
/>
|
||||
<BookingSide
|
||||
id={row.partnerBookingId}
|
||||
reference={
|
||||
row.partnerBooking?.reference ??
|
||||
row.partnerBookingReference
|
||||
}
|
||||
company={row.partnerBooking?.company?.name}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group gap={6} mt={12} c="dimmed">
|
||||
<Clock size={13} />
|
||||
<Text fz={12}>
|
||||
Requested {formatDateTime(row.requestedAt)}
|
||||
{row.requestedByName
|
||||
? ` by ${row.requestedByName}`
|
||||
: ""}
|
||||
{row.scheduledDate
|
||||
? ` · ships ${formatDateTime(row.scheduledDate)}`
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{row.status !== "PENDING" && (
|
||||
<Group gap={6} mt={6} c="dimmed" align="flex-start">
|
||||
<User size={13} style={{ marginTop: 2 }} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={12}>
|
||||
{STATUS_VERB[row.status]}{" "}
|
||||
{row.decidedByName ?? "an unknown user"}
|
||||
{row.decidedAt
|
||||
? ` on ${formatDateTime(row.decidedAt)}`
|
||||
: ""}
|
||||
</Text>
|
||||
{row.decisionNote && (
|
||||
<Text fz={12} fs="italic">
|
||||
“{row.decisionNote}”
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{row.status !== "APPROVED" && (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "approve" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
{row.status === "REJECTED"
|
||||
? "Approve anyway"
|
||||
: "Approve"}
|
||||
</Button>
|
||||
{row.status === "PENDING" && (
|
||||
<Button
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<X size={15} />}
|
||||
onClick={() => {
|
||||
setDecision({ row, kind: "reject" });
|
||||
setNote("");
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
{pageCount > 1 && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
mt={4}
|
||||
wrap="wrap"
|
||||
>
|
||||
<Text fz={12} c="dimmed">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–
|
||||
{Math.min(page * PAGE_SIZE, data?.total ?? 0)} of{" "}
|
||||
{data?.total ?? 0}
|
||||
</Text>
|
||||
<Pagination
|
||||
size="sm"
|
||||
radius="md"
|
||||
color="edr-ink"
|
||||
total={pageCount}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
disabled={isFetching}
|
||||
siblings={1}
|
||||
boundaries={1}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -194,17 +353,21 @@ export default function ConsolidationApprovalsPage() {
|
||||
radius="lg"
|
||||
title={
|
||||
<Text fw={800} fz={16}>
|
||||
{decision?.kind === "approve"
|
||||
? "Approve this shared wagon?"
|
||||
: "Reject this shared wagon?"}
|
||||
{decision?.kind !== "approve"
|
||||
? "Reject this shared wagon?"
|
||||
: decision.row.status === "REJECTED"
|
||||
? "Approve this rejected shared wagon?"
|
||||
: "Approve this shared wagon?"}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fz="sm" c="dimmed">
|
||||
{decision?.kind === "approve"
|
||||
? "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."
|
||||
: "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."}
|
||||
{decision?.kind !== "approve"
|
||||
? "Both bookings go back to GL as “changes requested” with your reason. Neither reaches Operations."
|
||||
: decision.row.status === "REJECTED"
|
||||
? "This pairing was rejected before. Approving it now overrides that decision — both bookings leave the gate together and continue to Operations."
|
||||
: "Both bookings leave the gate together and continue to Operations. Each is still invoiced and paid separately."}
|
||||
</Text>
|
||||
|
||||
<Textarea
|
||||
|
||||
@@ -147,13 +147,36 @@ export default function TrainScheduleV2DetailPage() {
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Live phase updates come from the booking-window socket (PHASE pushes
|
||||
// invalidate this query); 60s is the self-heal net for a missed emit so
|
||||
// the workspace countdown never freezes on an expired phase.
|
||||
// invalidate this query). The fast self-heal net is the one-row phase
|
||||
// heartbeat below — this long interval is only the last-resort refresh
|
||||
// for changes the schedule row itself never sees.
|
||||
refetchInterval: 300_000,
|
||||
}),
|
||||
);
|
||||
// One-row 60s heartbeat: refetch the (expensive) full detail only when the
|
||||
// schedule row actually changed — same freshness as polling the detail
|
||||
// itself, at a fraction of the server cost.
|
||||
const phaseQuery = useQuery(
|
||||
api.trainScheduling.schedulePhase.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
const lastPhaseSig = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!phaseQuery.data) return;
|
||||
const sig = JSON.stringify(phaseQuery.data);
|
||||
if (lastPhaseSig.current !== null && lastPhaseSig.current !== sig) {
|
||||
void detailQuery.refetch();
|
||||
}
|
||||
lastPhaseSig.current = sig;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phaseQuery.data]);
|
||||
useBookingWindowSocket(Boolean(scheduleId));
|
||||
const schedule = detailQuery.data;
|
||||
// Controlled so tab-scoped queries (eligible pool) pause on other tabs.
|
||||
const [activeTab, setActiveTab] = useState<string | null>("workflow");
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
@@ -221,7 +244,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const eligibleQuery = useQuery(
|
||||
api.trainScheduling.eligibleBookings.queryOptions({
|
||||
input: { filters: eligibleFilters, freightType: eligibleFreightType },
|
||||
enabled: Boolean(schedule),
|
||||
// The eligible pool feeds the Workflow tab's bookings step only — don't
|
||||
// fetch (or refetch on invalidation) while another tab is open.
|
||||
enabled: Boolean(schedule) && activeTab === "workflow",
|
||||
}),
|
||||
);
|
||||
const preview = useMutation(api.trainScheduling.preview.mutationOptions());
|
||||
@@ -1276,7 +1301,13 @@ export default function TrainScheduleV2DetailPage() {
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
radius="md"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<WorkflowIcon size={16} />}>
|
||||
Workflow
|
||||
|
||||
@@ -242,7 +242,10 @@ import {
|
||||
type UpdateTrainDetailsPayload,
|
||||
type UsedTrainNumbers,
|
||||
} from "./trainBuilder.service";
|
||||
import { trainSchedulingService } from "./trainScheduling.service";
|
||||
import {
|
||||
trainSchedulingService,
|
||||
type SchedulePhaseSnapshot,
|
||||
} from "./trainScheduling.service";
|
||||
import { truckTypesService, type TruckType } from "./truck-types.service";
|
||||
import { wagonTypesService, type WagonType } from "./wagon-types.service";
|
||||
import {
|
||||
@@ -382,6 +385,14 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(id),
|
||||
),
|
||||
|
||||
// One-row heartbeat behind the detail page's 60s poll — the giant detail
|
||||
// payload refetches only when this snapshot changes.
|
||||
schedulePhase: endpoint<{ id: string }, SchedulePhaseSnapshot>(
|
||||
"train-scheduling",
|
||||
"schedule-phase",
|
||||
({ id }) => trainSchedulingService.getSchedulePhase(id),
|
||||
),
|
||||
|
||||
eligibleBookings: endpoint<
|
||||
{ filters?: TrainScheduleFilters; freightType?: FreightType },
|
||||
EligibleContainerBookingsResponse
|
||||
@@ -457,15 +468,20 @@ export const api = {
|
||||
() => TRAIN_BUILDER_INVALIDATIONS,
|
||||
),
|
||||
|
||||
scheduleHistory: endpoint<{ scheduleId: string }, ScheduleHistoryEntry[]>(
|
||||
scheduleHistory: endpoint<
|
||||
{ scheduleId: string; page: number; pageSize: number },
|
||||
PaginatedResponse<ScheduleHistoryEntry>
|
||||
>(
|
||||
"train-scheduling",
|
||||
"schedule-history",
|
||||
({ scheduleId }) =>
|
||||
trainBuilderService.scheduleHistory(scheduleId).then((r) => r.data),
|
||||
({ scheduleId }) => [
|
||||
({ scheduleId, page, pageSize }) =>
|
||||
trainBuilderService.scheduleHistory(scheduleId, page, pageSize).then((r) => r.data),
|
||||
({ scheduleId, page, pageSize }) => [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"history",
|
||||
scheduleId,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface ConsolidationApprovalRow {
|
||||
requestedBy?: string | null;
|
||||
requestedAt: string;
|
||||
decidedBy?: string | null;
|
||||
/** Display name of the approver/rejecter — the id alone means nothing. */
|
||||
decidedByName?: string | null;
|
||||
requestedByName?: string | null;
|
||||
decidedAt?: string | null;
|
||||
decisionNote?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
@@ -35,6 +38,21 @@ export interface ConsolidationApprovalRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** One page of approval rows plus the whole-queue counts behind the tabs. */
|
||||
export interface ConsolidationApprovalPage {
|
||||
items: ConsolidationApprovalRow[];
|
||||
total: number;
|
||||
counts: Record<ConsolidationApprovalRow["status"], number>;
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses for grouped tabs */
|
||||
@@ -164,7 +182,9 @@ async function postBooking<T>(url: string, body?: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
export const bookingsService = {
|
||||
getListSummary: async (filter?: BookingListFilter): Promise<BookingListSummary> => {
|
||||
getListSummary: async (
|
||||
filter?: BookingListFilter,
|
||||
): Promise<BookingListSummary> => {
|
||||
const params: Record<string, string | number | boolean | undefined> = {};
|
||||
if (filter) {
|
||||
if (filter.statuses) params.statuses = filter.statuses;
|
||||
@@ -176,14 +196,16 @@ export const bookingsService = {
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
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.destinationYardId)
|
||||
params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
}
|
||||
@@ -203,22 +225,26 @@ export const bookingsService = {
|
||||
if (filter.pageSize != null) params.pageSize = filter.pageSize;
|
||||
if (filter.sortBy) params.sortBy = filter.sortBy;
|
||||
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
|
||||
if (filter.schedulingStatuses) params.schedulingStatuses = filter.schedulingStatuses;
|
||||
if (filter.assignedToSchedule) params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.schedulingStatuses)
|
||||
params.schedulingStatuses = filter.schedulingStatuses;
|
||||
if (filter.assignedToSchedule)
|
||||
params.assignedToSchedule = filter.assignedToSchedule;
|
||||
if (filter.companyId) params.companyId = filter.companyId;
|
||||
if (filter.contractId) params.contractId = filter.contractId;
|
||||
if (filter.freightType) params.freightType = filter.freightType;
|
||||
if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.bookingType) params.bookingType = filter.bookingType;
|
||||
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
|
||||
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.destinationYardId)
|
||||
params.destinationYardId = filter.destinationYardId;
|
||||
if (filter.isGovernment) params.isGovernment = filter.isGovernment;
|
||||
if (filter.customerKind) params.customerKind = filter.customerKind;
|
||||
if (filter.customsClearingEnabled)
|
||||
@@ -330,7 +356,9 @@ export const bookingsService = {
|
||||
getConsolidationDetails: async (
|
||||
id: string,
|
||||
): Promise<ConsolidationDetails> => {
|
||||
const response = await client.get<ConsolidationDetails>(B.CONSOLIDATION(id));
|
||||
const response = await client.get<ConsolidationDetails>(
|
||||
B.CONSOLIDATION(id),
|
||||
);
|
||||
return unwrap(response.data) as ConsolidationDetails;
|
||||
},
|
||||
|
||||
@@ -348,8 +376,7 @@ export const bookingsService = {
|
||||
|
||||
payBooking: (id: string) => postBooking<BookingDetail>(B.PAYMENT_PAY(id)),
|
||||
|
||||
startTransit: (id: string) =>
|
||||
postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||
startTransit: (id: string) => postBooking<BookingDetail>(B.START_TRANSIT(id)),
|
||||
|
||||
complete: (id: string) => postBooking<BookingDetail>(B.COMPLETE(id)),
|
||||
|
||||
@@ -358,10 +385,36 @@ export const bookingsService = {
|
||||
|
||||
// ── Shared-wagon approval gate ──────────────────────────────────────────
|
||||
|
||||
/** Pairings awaiting a decision, oldest first. */
|
||||
consolidationApprovalQueue: async (): Promise<ConsolidationApprovalRow[]> => {
|
||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as ConsolidationApprovalRow[];
|
||||
/**
|
||||
* One page of the gate. `status` picks the tab; the counts come back for all
|
||||
* three tabs regardless, so the badges show the whole queue and not the page.
|
||||
*/
|
||||
consolidationApprovalQueue: async (
|
||||
params: {
|
||||
status?: ConsolidationApprovalRow["status"];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<ConsolidationApprovalPage> => {
|
||||
const response = await client.get(B.CONSOLIDATION_APPROVAL_QUEUE, {
|
||||
params,
|
||||
});
|
||||
const data = unwrap(response.data) as ConsolidationApprovalPage | null;
|
||||
return (
|
||||
data ?? {
|
||||
items: [],
|
||||
total: 0,
|
||||
counts: { PENDING: 0, APPROVED: 0, REJECTED: 0 },
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/** Decision history for one booking's shared wagon — who, when, and why. */
|
||||
@@ -411,10 +464,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
create: async (payload: Record<string, unknown>): Promise<BookingDetail> => {
|
||||
const response = await client.post<{ booking: BookingDetail } | BookingDetail>(
|
||||
B.BASE,
|
||||
payload,
|
||||
);
|
||||
const response = await client.post<
|
||||
{ booking: BookingDetail } | BookingDetail
|
||||
>(B.BASE, payload);
|
||||
const data = unwrap(response.data) as { booking?: BookingDetail };
|
||||
return (data.booking ?? data) as BookingDetail;
|
||||
},
|
||||
@@ -433,7 +485,10 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
/** GL asks the customer for additional clearance document(s). */
|
||||
requestAdditionalDocuments: async (id: string, note: string): Promise<void> => {
|
||||
requestAdditionalDocuments: async (
|
||||
id: string,
|
||||
note: string,
|
||||
): Promise<void> => {
|
||||
await client.post(`/bookings/${id}/clearance/doc-requests`, { note });
|
||||
},
|
||||
|
||||
@@ -446,7 +501,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
// ── Clearance charges (post-finalization customer billing) ──
|
||||
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
|
||||
getClearanceCharges: async (
|
||||
id: string,
|
||||
): Promise<Freight.ClearanceCharge[]> => {
|
||||
const response = await client.get(`/bookings/${id}/clearance/charges`);
|
||||
return unwrap(response.data) as Freight.ClearanceCharge[];
|
||||
},
|
||||
@@ -510,7 +567,9 @@ export const bookingsService = {
|
||||
},
|
||||
|
||||
// ── Additional charges (ad-hoc finance billing) ──
|
||||
getAdditionalCharges: async (id: string): Promise<Freight.AdditionalCharge[]> => {
|
||||
getAdditionalCharges: async (
|
||||
id: string,
|
||||
): Promise<Freight.AdditionalCharge[]> => {
|
||||
const response = await client.get(`/bookings/${id}/additional-charges`);
|
||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||
},
|
||||
@@ -518,7 +577,13 @@ export const bookingsService = {
|
||||
/** Finance raises a new charge — 'draft' just saves it, 'send' also issues the invoice and notifies the customer. */
|
||||
createAdditionalCharge: async (
|
||||
id: string,
|
||||
payload: { reason: string; amount: number; currency: string; action: "draft" | "send"; file?: File | null },
|
||||
payload: {
|
||||
reason: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
action: "draft" | "send";
|
||||
file?: File | null;
|
||||
},
|
||||
): Promise<Freight.AdditionalCharge[]> => {
|
||||
const form = new FormData();
|
||||
form.append("reason", payload.reason);
|
||||
@@ -526,9 +591,13 @@ export const bookingsService = {
|
||||
form.append("currency", payload.currency);
|
||||
form.append("action", payload.action);
|
||||
if (payload.file) form.append("file", payload.file);
|
||||
const response = await client.post(`/bookings/${id}/additional-charges`, form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
const response = await client.post(
|
||||
`/bookings/${id}/additional-charges`,
|
||||
form,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
},
|
||||
);
|
||||
return unwrap(response.data) as Freight.AdditionalCharge[];
|
||||
},
|
||||
|
||||
@@ -613,12 +682,18 @@ export const bookingsService = {
|
||||
currency: string,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
files.forEach((file, index) => form.append(`draft_declaration_${index}`, file));
|
||||
files.forEach((file, index) =>
|
||||
form.append(`draft_declaration_${index}`, file),
|
||||
);
|
||||
form.append("price", String(price));
|
||||
form.append("currency", currency);
|
||||
const response = await client.post(B.CLEARANCE_DRAFT_DECLARATION(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
const response = await client.post(
|
||||
B.CLEARANCE_DRAFT_DECLARATION(id),
|
||||
form,
|
||||
{
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
},
|
||||
);
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
|
||||
@@ -460,8 +460,8 @@ export const trainBuilderService = {
|
||||
payload,
|
||||
),
|
||||
/** Unified wagon/booking change history for the schedule's History tab. */
|
||||
scheduleHistory: (scheduleId: string) =>
|
||||
apiClient.get<ScheduleHistoryEntry[]>(
|
||||
`/train-scheduling/schedules/${scheduleId}/history`,
|
||||
scheduleHistory: (scheduleId: string, page: number, pageSize: number) =>
|
||||
apiClient.get<PaginatedResponse<ScheduleHistoryEntry>>(
|
||||
`/train-scheduling/schedules/${scheduleId}/history?page=${page}&pageSize=${pageSize}`,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -51,12 +51,30 @@ interface BookingReferenceDataResponse {
|
||||
yard?: Array<YardOption & { label?: string }>;
|
||||
}
|
||||
|
||||
/** Lightweight polling snapshot — refetch the full detail only when this changes. */
|
||||
export interface SchedulePhaseSnapshot {
|
||||
status: string;
|
||||
bookingWindowStatus: string | null;
|
||||
windowPhase: string | null;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const pathsFor = (freightType?: FreightType) =>
|
||||
freightType === "BULK"
|
||||
? URL_CONSTANTS.TRAIN_SCHEDULING.BULK
|
||||
: URL_CONSTANTS.TRAIN_SCHEDULING.CONTAINER;
|
||||
|
||||
export const trainSchedulingService = {
|
||||
getSchedulePhase: async (id: string): Promise<SchedulePhaseSnapshot> => {
|
||||
const response = await client.get<SchedulePhaseSnapshot>(
|
||||
`/train-scheduling/schedules/${id}/phase`,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
getEligibleBookings: async (
|
||||
filters?: TrainScheduleFilters,
|
||||
freightType?: FreightType,
|
||||
|
||||
Reference in New Issue
Block a user