mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
implement batch board notification system for schedule changes and updates
This commit is contained in:
@@ -532,6 +532,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
||||
);
|
||||
}
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
|
||||
}
|
||||
|
||||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||||
@@ -766,6 +767,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (schedule && (await this.isTrainFull(schedule))) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
}
|
||||
this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
|
||||
}
|
||||
|
||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||
@@ -778,6 +780,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
if (unlinked.length > 0) {
|
||||
this.notifyBoardChanged(scheduleId, "paid_reconciled");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- legacy fill entry point ----------------------------------------------
|
||||
@@ -1159,7 +1164,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
||||
async runWagonAllocation(scheduleId: string) {
|
||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
const result =
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
if (result.assignedBookingIds.length > 0) {
|
||||
this.notifyBoardChanged(scheduleId, "wagon_allocation_run");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1329,6 +1339,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.resortPoolByPriority(pool);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let preempted = false;
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
@@ -1367,6 +1378,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
budget,
|
||||
wagonDims,
|
||||
);
|
||||
preempted = true;
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||
@@ -1415,6 +1427,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
// One push per fill pass (never per booking) — only when rows changed.
|
||||
if (reservedThisPass > 0 || armed || preempted) {
|
||||
this.notifyBoardChanged(scheduleId, "batch_fill");
|
||||
}
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
return commercialReserved;
|
||||
}
|
||||
@@ -1515,8 +1531,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
// Live per-schedule corridor budget + arm/changed flags, in departure order.
|
||||
const trains: Array<{
|
||||
id: string;
|
||||
budget: CorridorBudget;
|
||||
armed: boolean;
|
||||
changed: boolean;
|
||||
}> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1530,7 +1551,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
trains.push({ id, budget, armed: false });
|
||||
trains.push({ id, budget, armed: false, changed: false });
|
||||
}
|
||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||
|
||||
@@ -1603,6 +1624,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
t.budget,
|
||||
wagonDims,
|
||||
);
|
||||
// Preempt may have displaced (expired) victims even when the need
|
||||
// still doesn't fit — the board must refresh either way.
|
||||
t.changed = true;
|
||||
if (freed) {
|
||||
target = t;
|
||||
break;
|
||||
@@ -1647,6 +1671,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
target.changed = true;
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -1664,6 +1689,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const t of trains) {
|
||||
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
// One push per touched train per pass (never per booking). `armed` covers
|
||||
// commercial reserves + partial offers; `changed` covers gov allocations
|
||||
// and preemption.
|
||||
if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill");
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
@@ -1910,6 +1939,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`— payment phase extended for them`,
|
||||
);
|
||||
}
|
||||
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||
// this) so one settle produces one push, after every allocation/expiry/
|
||||
// top-up extension for this schedule has been persisted.
|
||||
this.notifyBoardChanged(scheduleId, "reservations_settled");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1962,6 +1995,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce that a schedule's batch-board data changed so open boards refetch.
|
||||
* Called AFTER the state change is persisted; a push failure only logs — it
|
||||
* must never break the business transaction that triggered it.
|
||||
*/
|
||||
private notifyBoardChanged(scheduleId: string, reason: string): void {
|
||||
try {
|
||||
this.bookingWindowGateway.emitBatchChanged(scheduleId, reason);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- staff override actions ----------------------------------------------
|
||||
|
||||
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
|
||||
@@ -1987,6 +2035,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||
}
|
||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2021,6 +2070,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
const sourceScheduleId = booking.trainScheduleId ?? null;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (booking.trainScheduleId) {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
@@ -2043,6 +2093,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
});
|
||||
// Both boards changed: the booking left the source train and joined the target.
|
||||
if (sourceScheduleId && sourceScheduleId !== newScheduleId) {
|
||||
this.notifyBoardChanged(sourceScheduleId, "booking_moved");
|
||||
}
|
||||
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||
}
|
||||
|
||||
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
||||
@@ -2060,6 +2115,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||||
}
|
||||
// After the top-up + phase extension so one push carries the final state.
|
||||
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2099,10 +2156,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.update(booking.id, { trainScheduleId: scheduleId });
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
return;
|
||||
}
|
||||
await this.reserve(booking, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
}
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
@@ -2365,7 +2424,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
day,
|
||||
);
|
||||
const leftovers = pool.filter((b) => !b.isGovernment);
|
||||
// Capture pinned schedules BEFORE expire() clears trainScheduleId, so each
|
||||
// touched board gets exactly one push at the end of the sweep.
|
||||
const touchedScheduleIds = new Set<string>();
|
||||
if (leftovers.length) touchedScheduleIds.add(scheduleId);
|
||||
for (const booking of leftovers) {
|
||||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||
await this.expire(booking, "no-capacity");
|
||||
}
|
||||
if (leftovers.length) {
|
||||
@@ -2374,6 +2438,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`expired ${leftovers.length} waiting booking(s)`,
|
||||
);
|
||||
}
|
||||
for (const id of touchedScheduleIds) {
|
||||
this.notifyBoardChanged(id, "day_pool_expired");
|
||||
}
|
||||
return leftovers.length;
|
||||
}
|
||||
|
||||
@@ -2436,7 +2503,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||
);
|
||||
}
|
||||
// Only bookings pinned to a train show on a board — collect their schedules
|
||||
// and push once per schedule after the sweep (most unaccepted rows are
|
||||
// unpinned under day-level pooling, so this usually emits nothing).
|
||||
const touchedScheduleIds = new Set<string>();
|
||||
for (const booking of unaccepted) {
|
||||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
@@ -2453,6 +2525,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
|
||||
);
|
||||
}
|
||||
for (const id of touchedScheduleIds) {
|
||||
this.notifyBoardChanged(id, "unaccepted_expired");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BOOKING_WINDOW_WS_EVENTS,
|
||||
BOOKING_WINDOW_WS_NAMESPACE,
|
||||
type BatchBoardChangedEvent,
|
||||
type BookingWindowPhaseEvent,
|
||||
} from '@edr/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection {
|
||||
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce that a schedule's batch-board data changed (payment, allocation,
|
||||
* fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries
|
||||
* no board data, only the scheduleId; clients holding that board refetch.
|
||||
*/
|
||||
emitBatchChanged(scheduleId: string, reason: string): void {
|
||||
const payload: BatchBoardChangedEvent = {
|
||||
scheduleId,
|
||||
reason,
|
||||
at: new Date().toISOString(),
|
||||
};
|
||||
this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload);
|
||||
}
|
||||
|
||||
private extractToken(socket: Socket): string | undefined {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) return authToken;
|
||||
|
||||
@@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit {
|
||||
.update(s.id, { docReviewCompletedAt: now });
|
||||
s.docReviewCompletedAt = now;
|
||||
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
|
||||
// The batch fill emits only for trains it actually reserved onto — this
|
||||
// covers the empty-pool case so every open board still refetches. Push
|
||||
// failures only log; the doc-review completion itself already persisted.
|
||||
try {
|
||||
this.gateway.emitBatchChanged(s.id, 'doc_review_completed');
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Batch-board push failed for ${s.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
return fresh ?? schedule;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Group,
|
||||
@@ -267,7 +267,13 @@ function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every
|
||||
// page render; both props keep their identity across unrelated page state
|
||||
// (React Query structural sharing + the page's useMemo'd bookings).
|
||||
export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||
data,
|
||||
bookings,
|
||||
}: Props) {
|
||||
const phase = data.windowPhase;
|
||||
const isPayPhase = phase === "PAYMENT";
|
||||
|
||||
@@ -558,7 +564,7 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/** Contextual banner describing the current window phase in plain language. */
|
||||
function PhaseBanner({ phase }: { phase: string | null }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BOOKING_WINDOW_WS_EVENTS,
|
||||
BOOKING_WINDOW_WS_NAMESPACE,
|
||||
type BatchBoardChangedEvent,
|
||||
type BookingWindowPhaseEvent,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -82,9 +83,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
|
||||
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||
socket.on("connect", () =>
|
||||
console.debug("[booking-windows] socket connected", socket.id),
|
||||
);
|
||||
let hadConnected = false;
|
||||
socket.on("connect", () => {
|
||||
console.debug("[booking-windows] socket connected", socket.id);
|
||||
// A RE-connect means pushes may have been missed while offline — refetch
|
||||
// every board view (list + any open detail share the "batch-board" key
|
||||
// prefix) once so the gap self-heals immediately.
|
||||
if (hadConnected) {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["train-scheduling", "batch-board"],
|
||||
});
|
||||
}
|
||||
hadConnected = true;
|
||||
});
|
||||
socket.on("connect_error", (err) =>
|
||||
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||
);
|
||||
@@ -144,6 +155,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
||||
},
|
||||
);
|
||||
|
||||
// Board-data pushes (payment settled, allocation, fill, expiry, …): refetch
|
||||
// the schedule's detail immediately, refresh the list debounced. The event
|
||||
// carries no data — the board views are rich, differently-shaped queries.
|
||||
socket.on(
|
||||
BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED,
|
||||
(event: BatchBoardChangedEvent) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
|
||||
});
|
||||
scheduleRefetch(false);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (refetchTimer) clearTimeout(refetchTimer);
|
||||
socket.off();
|
||||
|
||||
@@ -55,6 +55,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FREIGHT_BRAND, FREIGHT_BRAND_DARK } from "@/theme/freight-brand";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
|
||||
import type {
|
||||
BatchBoardFilters,
|
||||
BatchBoardSchedule,
|
||||
@@ -424,6 +425,8 @@ function CardSkeleton() {
|
||||
|
||||
export default function BatchBoardPage() {
|
||||
const navigate = useNavigate();
|
||||
// Live board: phase + batch-changed pushes invalidate the list query below.
|
||||
useBookingWindowSocket();
|
||||
const { viewMode, setViewMode } = useFleetViewMode("batch-board");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 12 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -484,7 +487,10 @@ export default function BatchBoardPage() {
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
...api.trainScheduling.batchBoard.queryOptions({ input: { filters } }),
|
||||
refetchInterval: 30_000,
|
||||
// Real-time updates come from the booking-window socket (batch-board:changed
|
||||
// + PHASE pushes invalidate this query); 60s is only a self-heal safety net
|
||||
// for a missed emit.
|
||||
refetchInterval: 60_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -377,7 +377,15 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
// Memoized: the page re-renders on unrelated state (tab switch, composition
|
||||
// booking selection, background-fetch flags) while the bookings arrays keep
|
||||
// their identity (useMemo + React Query structural sharing) — skip re-rendering
|
||||
// the whole table in those cases.
|
||||
const BookingTable = memo(function BookingTable({
|
||||
bookings,
|
||||
}: {
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={BOOKING_COLUMNS}
|
||||
@@ -387,7 +395,7 @@ function BookingTable({ bookings }: { bookings: BatchBoardBookingDetail[] }) {
|
||||
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
const chips: Array<{ value: number; color: string; label: string }> = [
|
||||
@@ -581,18 +589,10 @@ export default function BatchScheduleDetailPage() {
|
||||
api.trainScheduling.batchBoardDetail.queryOptions({
|
||||
input: { scheduleId: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId),
|
||||
// Poll fast while a window cycle is actively moving (open / doc-review /
|
||||
// payment) so the priority ranking + pay countdowns stay live; back off to
|
||||
// 30s once the cycle is idle (pre-window / closed / done).
|
||||
refetchInterval: (query) => {
|
||||
const phase = (query.state.data as BatchBoardScheduleDetail | undefined)
|
||||
?.windowPhase;
|
||||
return phase === "OPEN" ||
|
||||
phase === "DOC_REVIEW" ||
|
||||
phase === "PAYMENT"
|
||||
? 5_000
|
||||
: 30_000;
|
||||
},
|
||||
// Real-time updates come from the booking-window socket (batch-board:changed
|
||||
// + PHASE pushes invalidate this query); 60s is only a self-heal safety net
|
||||
// for a missed emit.
|
||||
refetchInterval: 60_000,
|
||||
}),
|
||||
);
|
||||
// Keep the board in sync with server-pushed window-phase transitions too
|
||||
@@ -688,6 +688,10 @@ export default function BatchScheduleDetailPage() {
|
||||
null,
|
||||
);
|
||||
|
||||
// Stable identity so the memoized BookingsManager isn't re-rendered by
|
||||
// unrelated page state (tab switches, composition selection, fetch flags).
|
||||
const handleBookingsChanged = useCallback(() => void refetch(), [refetch]);
|
||||
|
||||
const handleCompleteDocReview = () => {
|
||||
completeDocReview
|
||||
.mutateAsync(scheduleId ?? "")
|
||||
@@ -1002,7 +1006,7 @@ export default function BatchScheduleDetailPage() {
|
||||
<BookingsManager
|
||||
scheduleId={scheduleId ?? ""}
|
||||
bookings={allBookings}
|
||||
onChanged={() => void refetch()}
|
||||
onChanged={handleBookingsChanged}
|
||||
readOnly={bookingsReadOnly}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -118,8 +118,11 @@ export interface BookingsManagerProps {
|
||||
* allocation status, and remove or re-assign bookings individually or in bulk.
|
||||
* Wraps the shared DataTable; selection + actions are handled locally so the
|
||||
* surrounding accordion / tab layout stays untouched.
|
||||
*
|
||||
* Memoized: the detail page passes stable props (memoized bookings array +
|
||||
* useCallback onChanged), so its unrelated re-renders skip this subtree.
|
||||
*/
|
||||
export function BookingsManager({
|
||||
export const BookingsManager = memo(function BookingsManager({
|
||||
scheduleId,
|
||||
bookings,
|
||||
onChanged,
|
||||
@@ -620,6 +623,6 @@ export function BookingsManager({
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default BookingsManager;
|
||||
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
RouteCorridor,
|
||||
StatusPill,
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
@@ -169,7 +169,14 @@ export default function TrainScheduleV2ListPage() {
|
||||
);
|
||||
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({ input: { filters } }),
|
||||
api.trainScheduling.scheduleList.queryOptions({
|
||||
input: { filters },
|
||||
// Keep the previous page on screen while the next page/filter result
|
||||
// loads instead of flashing the empty state; 30s staleTime spares
|
||||
// back-and-forth navigation from refetching an unchanged list.
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
// Yard options for the origin/destination filters (shared routes reference
|
||||
// list, so the choices don't shrink to whatever the current page shows).
|
||||
|
||||
@@ -32,9 +32,22 @@ export interface BookingWindowPhaseEvent {
|
||||
scheduledDepartureDate: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload pushed whenever server-side state behind a schedule's batch board
|
||||
* changes outside a phase transition (payment settled, allocation run, batch
|
||||
* fill, reservation expiry, doc review, …). Carries no board data — clients
|
||||
* refetch the views they hold; `reason` exists for debugging/telemetry only.
|
||||
*/
|
||||
export interface BatchBoardChangedEvent {
|
||||
scheduleId: string;
|
||||
reason: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** Socket.io event names pushed server → client on the booking-windows namespace. */
|
||||
export const BOOKING_WINDOW_WS_EVENTS = {
|
||||
PHASE: "booking-window:phase",
|
||||
BATCH_CHANGED: "batch-board:changed",
|
||||
} as const;
|
||||
|
||||
/** Socket.io namespace the booking-window gateway listens on. */
|
||||
|
||||
Reference in New Issue
Block a user