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:
Marshal
2026-08-23 04:49:58 +00:00
parent 8e6fc09aac
commit e2189040fa
15 changed files with 1746 additions and 613 deletions

View File

@@ -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

View File

@@ -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