From 3447394e3214830749e1b529a3131be0a1df436d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 5 Jul 2026 10:22:58 +0000 Subject: [PATCH] add bookings management and settings --- .../dto/update-schedule-window-rule.dto.ts | 62 ++ .../train-scheduling.controller.ts | 15 + .../train-scheduling.service.ts | 100 ++ .../BookingWindowSettingsModal.tsx | 409 ++++++++ .../backoffice/src/constants/URLS.ts | 2 + .../TrainScheduleV2DetailPage.tsx | 26 +- .../TrainScheduleV2ListPage.tsx | 18 + .../TrainSchedulingGlobalRulesPage.tsx | 16 +- .../backoffice/src/services/api.ts | 13 + .../src/services/trainScheduling.service.ts | 12 + .../backoffice/src/types/trainScheduling.ts | 25 + apps/edr-freight-web/portal/src/App.tsx | 16 +- .../src/pages/bookings/BookingsListPage.tsx | 912 ++++++++++++++++++ 13 files changed, 1617 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingsListPage.tsx diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts new file mode 100644 index 000000000..9232de29e --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-window-rule.dto.ts @@ -0,0 +1,62 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; + +/** + * Per-schedule booking-window rule override (staff action on the ops board). + * Every field is optional — only the ones sent are changed; the rest keep the + * schedule's existing snapshot. Mirrors the window fields of the global rules DTO. + */ +export class UpdateScheduleWindowRuleDto { + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ + example: 17, + description: + 'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowCloseHour?: number; + + @ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.0166) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ + example: 3, + description: 'Days before departure the booking window starts (re-derives the window start)', + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 773e4738a..38ebd7be5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -42,6 +42,7 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; +import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; import { BookingWindowService } from "./booking-window.service"; @@ -519,6 +520,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/window-rule") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", + }) + async updateScheduleWindowRule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleWindowRuleDto, + ) { + await this.trainSchedulingService.updateScheduleWindowRule(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/doc-review-complete") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 1f676f0f3..877d58786 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -64,6 +64,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, @@ -331,6 +332,87 @@ export class TrainSchedulingService { return saved; } + /** + * Override the booking-window rule for ONE schedule (staff action on the ops + * board). Only the fields provided are changed; the rest keep the schedule's + * existing snapshot (falling back to the live global config for legacy rows). + * The window must not have opened yet — an OPEN/past schedule stays frozen so + * customers keep the times they were shown. windowOpensAt/ClosesAt are + * re-derived from the merged rule, and the snapshot is updated so the board + * draws the new cycles. + */ + async updateScheduleWindowRule( + id: string, + dto: UpdateScheduleWindowRuleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (schedule.windowPhase !== 'PRE_WINDOW') { + throw new BadRequestException( + 'Booking window settings can only be changed before the window opens ' + + `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`, + ); + } + const now = new Date(); + if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) { + throw new BadRequestException( + 'This schedule has already departed or has no departure date.', + ); + } + + // Merge the override onto the schedule's current effective rule (its snapshot, + // or the live config where a legacy row has no snapshot). + const liveCfg = await this.getWindowConfig(); + const merged: BookingWindowConfig = { + importWindowLeadDays: + dto.importWindowLeadDays ?? + schedule.ruleImportWindowLeadDays ?? + liveCfg.importWindowLeadDays, + exportBookingLeadHours: + schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, + windowOpenHour: + dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, + windowCloseHour: + dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour, + windowDurationHours: + dto.windowDurationHours ?? + (schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : liveCfg.windowDurationHours), + // The reopen gap is doc review + payment; keep the config values unless the + // override changes them, so the derived snapshot delay stays consistent. + docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, + paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, + reopenDelayMinutes: liveCfg.reopenDelayMinutes, + }; + + if (merged.windowCloseHour < merged.windowOpenHour) { + throw new BadRequestException( + `Window close hour (${merged.windowCloseHour}) must be on or after the open hour ` + + `(${merged.windowOpenHour}); set them equal for a 24-hour desk.`, + ); + } + + const times = + schedule.direction === 'EXPORT' + ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) + : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); + + await this.dataSource.getRepository(TrainSchedule).update(id, { + windowOpensAt: times.windowOpensAt, + windowClosesAt: times.windowClosesAt, + ...windowRuleSnapshot(merged), + }); + this.logger.log( + `Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`, + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure @@ -3677,6 +3759,8 @@ export class TrainSchedulingService { .flatMap((w) => w.allocations ?? []) .map((a) => a.id); + const windowCfg = await this.getWindowConfig(); + const [containerItems, bulkLoads] = await Promise.all([ allocationIds.length ? this.wagonAllocationContainerItemsRepository.findAll({ @@ -3723,6 +3807,22 @@ export class TrainSchedulingService { paymentPhaseEndsAt: schedule.paymentPhaseEndsAt ? schedule.paymentPhaseEndsAt.toISOString() : null, + // Per-schedule booking-window rule snapshot — powers the "Booking window + // settings" editor on the ops board (prefill + save one schedule's + // override). docReview/payment are not snapshotted per schedule (only their + // sum, as reopenDelayMinutes), so the editor prefills them from live config. + windowRule: { + windowOpenHour: schedule.ruleWindowOpenHour ?? null, + windowCloseHour: schedule.ruleWindowCloseHour ?? null, + windowDurationHours: + schedule.ruleWindowDurationHours != null + ? Number(schedule.ruleWindowDurationHours) + : null, + reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, + importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, + docReviewMinutes: windowCfg.docReviewMinutes, + paymentWindowMinutes: windowCfg.paymentWindowMinutes, + }, route: schedule.route ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) } : null, diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx new file mode 100644 index 000000000..e31ad6a20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/BookingWindowSettingsModal.tsx @@ -0,0 +1,409 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Button, + Divider, + Group, + Loader, + Modal, + NumberInput, + Select, + Stack, + Switch, + Text, + ThemeIcon, +} from "@mantine/core"; +import { isAxiosError } from "axios"; +import { Clock, Info, Moon, Sun } from "lucide-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; + +import DurationField from "@/components/trainScheduling/DurationField"; +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling"; + +/** Fallbacks matching the API's global-rules defaults (used when a field is null). */ +const DEFAULTS = { + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + importWindowLeadDays: 3, +}; + +/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ +function hourLabel(hour: number): string { + const period = hour < 12 ? "AM" : "PM"; + const h12 = hour % 12 === 0 ? 12 : hour % 12; + return `${h12}:00 ${period}`; +} + +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ + value: String(h), + label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`, +})); + +interface FormState { + windowOpenHour: number; + windowCloseHour: number; + windowDurationHours: number | ""; + docReviewMinutes: number | ""; + paymentWindowMinutes: number | ""; + importWindowLeadDays: number | ""; +} + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +export interface BookingWindowSettingsModalProps { + scheduleId: string | null; + opened: boolean; + onClose: () => void; + /** Called after a successful save (e.g. to refetch a list). */ + onSaved?: () => void; +} + +/** + * Per-schedule booking-window settings editor. Prefills from the schedule's own + * rule snapshot, lets staff tune the daily desk hours / durations for just that + * train, and saves an override. Only editable before the window opens. + */ +export default function BookingWindowSettingsModal({ + scheduleId, + opened, + onClose, + onSaved, +}: BookingWindowSettingsModalProps) { + const { toast } = useToast(); + + const detailQuery = useQuery({ + ...api.trainScheduling.scheduleDetail.queryOptions({ + input: { id: scheduleId ?? "" }, + }), + enabled: opened && Boolean(scheduleId), + }); + const schedule = detailQuery.data; + + const save = useMutation( + api.trainScheduling.updateScheduleWindowRule.mutationOptions(), + ); + + const [form, setForm] = useState(null); + + // Seed the form from the schedule's snapshot once it loads (or when reopened). + useEffect(() => { + if (!opened || !schedule) return; + const r = schedule.windowRule; + setForm({ + windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour, + windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour, + windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours, + docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes, + paymentWindowMinutes: + r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, + importWindowLeadDays: + r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, + }); + }, [opened, schedule]); + + const isExport = schedule?.direction === "EXPORT"; + const canEdit = schedule?.windowPhase === "PRE_WINDOW"; + const is24h = + form != null && form.windowOpenHour === form.windowCloseHour; + const closeBeforeOpen = + form != null && form.windowCloseHour < form.windowOpenHour; + + const reopenSummary = useMemo(() => { + if (!form) return ""; + const doc = Number(form.docReviewMinutes) || 0; + const pay = Number(form.paymentWindowMinutes) || 0; + const total = doc + pay; + const h = Math.floor(total / 60); + const m = total % 60; + const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean); + return parts.length ? parts.join(" ") : "0m"; + }, [form]); + + const handleSave = async () => { + if (!scheduleId || !form) return; + // Numeric fields must hold real values. + const duration = Number(form.windowDurationHours); + const doc = Number(form.docReviewMinutes); + const pay = Number(form.paymentWindowMinutes); + const lead = Number(form.importWindowLeadDays); + if ( + form.windowDurationHours === "" || + form.docReviewMinutes === "" || + form.paymentWindowMinutes === "" || + form.importWindowLeadDays === "" || + !Number.isFinite(duration) || + !Number.isFinite(doc) || + !Number.isFinite(pay) || + !Number.isFinite(lead) + ) { + toast({ + title: "Fill every field before saving", + variant: "destructive", + }); + return; + } + if (closeBeforeOpen) { + toast({ + title: "Close hour must be on or after the open hour", + description: "Set them equal for a 24-hour desk.", + variant: "destructive", + }); + return; + } + + const payload: UpdateScheduleWindowRulePayload = { + windowOpenHour: form.windowOpenHour, + windowCloseHour: form.windowCloseHour, + windowDurationHours: duration, + docReviewMinutes: doc, + paymentWindowMinutes: pay, + importWindowLeadDays: lead, + }; + + try { + await save.mutateAsync({ id: scheduleId, payload }); + toast({ title: "Booking window settings updated" }); + onSaved?.(); + onClose(); + } catch (err) { + toast({ + title: "Update failed", + description: parseError(err, "Could not update booking window"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Booking window settings + + + {schedule?.route?.name ?? "This schedule only"} + + + + } + > + {detailQuery.isLoading || !form ? ( + + + + ) : !canEdit ? ( + } + title="Window already open" + > + Booking window settings can only be changed before the window opens. + This schedule is currently{" "} + {String(schedule?.windowPhase ?? "not window-managed")}. + + ) : ( + + {isExport ? ( + }> + Export schedules use a single FCFS lead window — the daily desk + hours below don't apply, only the lead time does. + + ) : null} + + {/* ── Daily desk hours ─────────────────────────────────────────── */} + + + + Daily desk hours (EAT) + + {is24h ? ( + } + > + 24-hour desk + + ) : ( + } + > + {hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)} + + )} + + + + v != null && + setForm((f) => f && { ...f, windowCloseHour: Number(v) }) + } + allowDeselect={false} + comboboxProps={{ withinPortal: true }} + error={closeBeforeOpen ? "Must be ≥ open hour" : undefined} + disabled={isExport} + /> + + + setForm((f) => { + if (!f) return f; + // On → close == open (24h desk). Off → restore a normal ~9h + // day, always kept ≥ open hour so it never lands invalid. + const close = e.currentTarget.checked + ? f.windowOpenHour + : Math.min(23, f.windowOpenHour + 9); + return { ...f, windowCloseHour: close }; + }) + } + /> + {!isExport ? ( + + A not-yet-full train pauses at the close hour and resumes the next + morning at the open hour, every day until it fills or departs. + + ) : null} + + + + + {/* ── Cycle timing ─────────────────────────────────────────────── */} + + + Cycle timing + + + + setForm((f) => f && { ...f, windowDurationHours: v }) + } + min={0.0166} + disabled={isExport} + /> + + + setForm((f) => f && { ...f, docReviewMinutes: v }) + } + min={0} + disabled={isExport} + /> + + setForm((f) => f && { ...f, paymentWindowMinutes: v }) + } + min={1} + disabled={isExport} + /> + + {!isExport ? ( + + Reopen gap after each cycle = document review + payment ={" "} + {reopenSummary}. + + ) : null} + + + + + + {/* ── Lead time ────────────────────────────────────────────────── */} + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8112b4299..e1199b4ed 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -281,6 +281,8 @@ export const URL_CONSTANTS = { `/train-scheduling/schedules/${id}/assign-unassigned-booking`, BOOKING_WINDOW: (id: string) => `/train-scheduling/schedules/${id}/booking-window`, + WINDOW_RULE: (id: string) => + `/train-scheduling/schedules/${id}/window-rule`, CONTRACT_BOOKING_WINDOWS: (contractId: string) => `/train-scheduling/contracts/${contractId}/booking-windows`, MARK_BOOKING_PAID: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 958049a63..1c7110a5e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -20,6 +20,7 @@ import { ArrowLeft, CalendarClock, CheckCircle2, + Clock, Container as ContainerIcon, Eye, FileText, @@ -47,7 +48,8 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; -import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; +import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; +// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel"; import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge"; @@ -95,6 +97,7 @@ export default function TrainScheduleV2DetailPage() { const [previewResult, setPreviewResult] = useState(null); const [containerPlacements, setContainerPlacements] = useState([]); const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [windowSettingsOpen, setWindowSettingsOpen] = useState(false); const [gatepassSecuredAt, setGatepassSecuredAt] = useState(""); const [gatepassReference, setGatepassReference] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState(""); @@ -886,6 +889,18 @@ export default function TrainScheduleV2DetailPage() { Track train ) : null} + {schedule.windowPhase === "PRE_WINDOW" ? ( + + ) : null} {["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( + ); + } + // CHANGES_REQUESTED + clearance/operation steps are handled in place by a + // modal (update & resubmit, upload clearance docs, schedule & proceed). + if (bookingHasInlineAction(booking)) { + return ; + } + const payableStatus = isGeneralContract + ? "FULLY_EXECUTED" + : "SELECTED_FOR_BATCH"; + if (status === payableStatus && booking.paymentStatus !== "PAID") { + return ; + } + return ( + + ); +} + +function ColHeader({ label }: { label: string }) { + return ( + + {label} + + ); +} + +const hMeta = { headerClassName: "bg-[#F4F7FA]" }; + +function fmtDate(iso?: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +// Lightweight count query for a single lifecycle filter (reads only `total`). +function useStatusCount(statuses: string | undefined): number | undefined { + const { data } = useQuery( + api.bookings.list.queryOptions({ + input: { statuses, page: 1, pageSize: 1 }, + staleTime: 30_000, + }), + ); + return data?.meta?.total; +} + +function StatCard({ + card, + active, + count, + onSelect, +}: { + card: (typeof STAT_CARDS)[number]; + active: boolean; + count: number | undefined; + onSelect: () => void; +}) { + const Icon = card.icon; + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelect(); + } + }} + p="md" + radius="lg" + withBorder + style={{ + cursor: "pointer", + transition: "box-shadow 140ms ease, border-color 140ms ease", + borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)", + boxShadow: active ? "0 0 0 1px #F2A516" : "none", + }} + > + + + + + + + {count ?? "—"} + + + {card.label} + + + + + ); +} + +export default function BookingsListPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [statusFilter, setStatusFilter] = useState("all"); + const [query, setQuery] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + const [freightFilter, setFreightFilter] = useState(null); + const [sort, setSort] = useState("createdAt:DESC"); + const [createdFrom, setCreatedFrom] = useState(""); + const [createdTo, setCreatedTo] = useState(""); + const [trackingBooking, setTrackingBooking] = + useState(null); + + const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses; + const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"]; + + const resetPage = () => + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + + const selectFilter = (key: StatusFilterKey) => { + setStatusFilter(key); + resetPage(); + }; + + const hasExtraFilters = + !!typeFilter || !!freightFilter || !!createdFrom || !!createdTo; + const clearExtraFilters = () => { + setTypeFilter(null); + setFreightFilter(null); + setCreatedFrom(""); + setCreatedTo(""); + resetPage(); + }; + + const filter: BookingListFilter = useMemo( + () => ({ + statuses, + bookingType: typeFilter ?? undefined, + freightType: freightFilter ?? undefined, + createdFrom: createdFrom || undefined, + // include the whole selected end day + createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy, + sortOrder, + }), + [ + statuses, + typeFilter, + freightFilter, + createdFrom, + createdTo, + pagination.pageIndex, + pagination.pageSize, + sortBy, + sortOrder, + ], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + // Per-card lifecycle counts (one cheap query each, total-only). + const allCount = useStatusCount(undefined); + const activeCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "active")!.statuses, + ); + const paymentCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "payment")!.statuses, + ); + const draftCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "draft")!.statuses, + ); + const doneCount = useStatusCount( + STATUS_FILTERS.find((f) => f.key === "done")!.statuses, + ); + const cardCounts: Record = { + all: allCount, + active: activeCount, + payment: paymentCount, + draft: draftCount, + done: doneCount, + transit: undefined, + closed: undefined, + }; + + const allItems = data?.items ?? []; + const total = data?.meta?.total ?? allItems.length; + + // Server handles status + pagination; reference search is applied on the page. + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return allItems; + return allItems.filter((b) => + [b.reference, b.originYard?.label, b.destinationYard?.label] + .filter(Boolean) + .some((v) => String(v).toLowerCase().includes(q)), + ); + }, [allItems, query]); + + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const showEmpty = !isLoading && !isError && rows.length === 0; + + const columns: ColumnDef[] = [ + { + id: "booking", + size: 244, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original; + const cargoLabel = + b.freightType === "BULK" ? "Bulk cargo" : "Container"; + return ( + + + + + + + {b.reference} + + + {cargoLabel} + + + + ); + }, + }, + { + id: "type", + size: 150, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "cargo", + size: 168, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "route", + size: 196, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original; + const origin = b.originYard?.label ?? b.originYard?.code ?? "—"; + const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—"; + const sub = fmtDate(b.scheduledDate ?? b.createdAt); + return ( + + + {origin} → {dest} + + {sub && ( + + {sub} + + )} + + ); + }, + }, + { + id: "payment", + size: 130, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "scheduling", + size: 140, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "status", + size: 190, + meta: hMeta, + header: () => , + cell: ({ row }) => , + }, + { + id: "amount", + size: 140, + meta: hMeta, + header: () => , + cell: ({ row }) => { + const b = row.original as Freight.IBooking & { + totalAmount?: number; + amount?: number; + }; + const amount = b.totalAmount ?? b.amount ?? null; + if (!amount) { + return ( + + — + + ); + } + return ( + + ETB {amount.toLocaleString()} + + ); + }, + }, + { + id: "actions", + meta: hMeta, + header: () => null, + cell: ({ row }) => { + const booking = row.original; + const trackable = TRACKABLE_STATUSES.has(booking.status); + return ( + e.stopPropagation()} + > + {trackable && ( + + )} + + + + + + + + + navigate(`/bookings/${booking.id}`)}> + View details + + {trackable && ( + } + onClick={() => setTrackingBooking(booking)} + > + Track shipment + + )} + + + + ); + }, + }, + ]; + + return ( + + + {/* ── Page header ─────────────────────────────────────────────── */} + + + + + Bookings + + + + Track every cargo booking — from draft to delivery. + + + + + + {/* ── Summary stat cards ──────────────────────────────────────── */} + + {STAT_CARDS.map((card) => ( + selectFilter(card.key)} + /> + ))} + + + {/* ── Bookings table card ──────────────────────────────────────── */} + + + + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + radius="md" + style={{ flex: 1, minWidth: 200, maxWidth: 340 }} + /> + { + setTypeFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 170 }} + aria-label="Filter by booking type" + /> + ({ + value: o.value, + label: o.label, + }))} + value={sort} + onChange={(v) => { + setSort(v ?? "createdAt:DESC"); + resetPage(); + }} + allowDeselect={false} + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 160 }} + aria-label="Sort bookings" + /> + { + setCreatedFrom(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 150 }} + aria-label="Created from" + placeholder="From" + /> + { + setCreatedTo(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 150 }} + aria-label="Created to" + placeholder="To" + /> + {hasExtraFilters && ( + + )} + + + {total} booking{total !== 1 ? "s" : ""} + + + + {showEmpty ? ( + + + + + + {query + ? "No bookings match your search" + : "No bookings here yet"} + + + {query + ? "Try a different reference or clear the search." + : "Bookings are created against a contract. Open a contract to book a shipment."} + + {!query && ( + + )} + + ) : ( + + navigate(`/bookings/${(row as Freight.IBooking).id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none rounded-none" + footer={DataTableFooter} + /> + )} + + + + setTrackingBooking(null)} + bookingId={trackingBooking?.id ?? ""} + bookingReference={trackingBooking?.reference ?? ""} + originLabel={ + trackingBooking?.originYard?.label ?? + trackingBooking?.originYard?.code + } + destinationLabel={ + trackingBooking?.destinationYard?.label ?? + trackingBooking?.destinationYard?.code + } + /> + + ); +}