Enhance contract management by adding booking windows section and updating invoice logic for offloaded cargo

This commit is contained in:
Marshal
2026-07-07 09:49:12 +00:00
parent f4d28e77fa
commit 1c18fdbd52
8 changed files with 422 additions and 22 deletions

View File

@@ -395,15 +395,21 @@ export class GlOperationsService {
} }
if (!file) throw new BadRequestException('Attach the invoice document.'); if (!file) throw new BadRequestException('Attach the invoice document.');
// Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
// DJ doc milestone that may never be recorded — once the Djibouti gate pass
// is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
const milestones = await this.milestoneService.listForBooking(bookingId); const milestones = await this.milestoneService.listForBooking(bookingId);
const offloaded = milestones.find( const offloaded = milestones.find(
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
); );
if (!offloaded) { if (!offloaded) {
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException( throw new BadRequestException(
'Cargo must be offloaded before the final invoice can be raised.', 'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
); );
} }
}
const existing = await this.billingService.findInvoice( const existing = await this.billingService.findInvoice(
Freight.InvoiceSource.Booking, Freight.InvoiceSource.Booking,

View File

@@ -646,7 +646,9 @@ function FinalInvoiceStep({
const invoice = clearance.finalInvoice ?? null; const invoice = clearance.finalInvoice ?? null;
const paid = invoice?.status === "PAID"; const paid = invoice?.status === "PAID";
if (!clearance.offloaded && !invoice) { // Export: OFFLOADED is a DJ doc milestone that may never be recorded, so the
// secured gate pass is enough to open invoicing. Sending an invoice is optional.
if (!clearance.offloaded && !clearance.gatepassGranted && !invoice) {
return ( return (
<StepStatus <StepStatus
done={false} done={false}
@@ -740,7 +742,7 @@ function FinalInvoiceStep({
) : canDjAct && bookingId ? ( ) : canDjAct && bookingId ? (
<> <>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Cargo offloaded send the final invoice to the customer. Send the final invoice to the customer if post-arrival charges apply (optional).
</Text> </Text>
<Button <Button
color="edr-green" color="edr-green"

View File

@@ -21,7 +21,28 @@ import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling";
/**
* The fields a window card needs. Structural so both `StaffBookingWindow`
* (all-lanes staff feed) and `BookingWindow` (contract-scoped feed, which
* carries no train number) satisfy it.
*/
interface WindowRow {
scheduleId: string;
trainNumber?: string | null;
direction: string | null;
windowPhase: string | null;
isOpenNow: boolean;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string;
bookingCycleNo: number;
departureDate: string;
origin: string | null;
destination: string | null;
}
/** All window times are communicated in East Africa Time. */ /** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa"; const TZ = "Africa/Addis_Ababa";
@@ -46,7 +67,7 @@ function fmtTime(iso: string): string {
}); });
} }
function windowLabel(w: StaffBookingWindow): string { function windowLabel(w: WindowRow): string {
if (w.windowOpensAt && w.windowClosesAt) { if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime( return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt, w.windowClosesAt,
@@ -64,7 +85,7 @@ function windowLabel(w: StaffBookingWindow): string {
* between refetches announces what comes next rather than the bare "Expired". * between refetches announces what comes next rather than the bare "Expired".
*/ */
function phaseCountdown( function phaseCountdown(
w: StaffBookingWindow, w: WindowRow,
): { label: string; deadline: string; expiredText: string } | null { ): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) { switch (w.windowPhase) {
case "PRE_WINDOW": case "PRE_WINDOW":
@@ -105,7 +126,7 @@ function phaseCountdown(
} }
/** Drop windows whose booking window (or the train itself) has already passed. */ /** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: StaffBookingWindow): boolean { function isPast(w: WindowRow): boolean {
const now = Date.now(); const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null; const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null; const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
@@ -116,7 +137,7 @@ function isPast(w: StaffBookingWindow): boolean {
return false; return false;
} }
function WindowCard({ w }: { w: StaffBookingWindow }) { function WindowCard({ w }: { w: WindowRow }) {
const cd = phaseCountdown(w); const cd = phaseCountdown(w);
const open = w.isOpenNow; const open = w.isOpenNow;
const isImport = w.direction === "IMPORT"; const isImport = w.direction === "IMPORT";
@@ -218,21 +239,45 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
); );
} }
/** interface GlUpcomingWindowsSectionProps {
* All announced booking windows (import cycles + export FCFS) across every lane, /**
* shown to GL ET on the clearance queue as a paged carousel — three lanes per * Scope the card to one contract: only windows on that contract's routes
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card. * (and therefore its import/export direction) are shown. Omit for the
* Hidden when nothing is pending. * all-lanes staff feed on the clearance queue.
*/ */
export function GlUpcomingWindowsSection() { contractId?: string;
}
/**
* Announced booking windows (import cycles + export FCFS) as a paged carousel —
* three lanes per page, arrows to flip. Without `contractId` it shows every
* lane (GL ET clearance queue); with `contractId` it shows only the windows
* matching that contract's routes/direction (clearance detail page). Mirrors
* the customer's portal "Booking Windows" card. Hidden when nothing is pending.
*/
export function GlUpcomingWindowsSection({
contractId,
}: GlUpcomingWindowsSectionProps = {}) {
// Live pushes flip cards the moment the window engine transitions a phase; // Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback. // the 60s poll below stays only as a fallback.
useBookingWindowSocket(); useBookingWindowSocket();
const { data, isLoading } = useQuery( const allLanes = useQuery({
api.trainScheduling.allBookingWindows.queryOptions({ ...api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000, refetchInterval: 60_000,
}), }),
); enabled: !contractId,
});
const contractLanes = useQuery({
...api.trainScheduling.contractBookingWindows.queryOptions({
input: { contractId: contractId ?? "" },
refetchInterval: 60_000,
}),
enabled: Boolean(contractId),
});
const data: WindowRow[] | undefined = contractId
? contractLanes.data
: allLanes.data;
const isLoading = contractId ? contractLanes.isLoading : allLanes.isLoading;
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const windows = useMemo(() => { const windows = useMemo(() => {
@@ -270,7 +315,9 @@ export function GlUpcomingWindowsSection() {
Booking windows Booking windows
</Text> </Text>
<Text fz={13} c="dimmed"> <Text fz={13} c="dimmed">
Import and export booking windows across all lanes (EAT) {contractId
? "Booking windows on this contract's routes (EAT)"
: "Import and export booking windows across all lanes (EAT)"}
</Text> </Text>
</Box> </Box>
</Group> </Group>

View File

@@ -30,6 +30,7 @@ import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader"; import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection"; import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel"; import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useFileViewer } from "@/hooks/useFileViewer"; import { useFileViewer } from "@/hooks/useFileViewer";
@@ -199,6 +200,10 @@ export default function ContractClearanceDetailPage() {
<ClearanceHero contract={contract} stats={stats} /> <ClearanceHero contract={contract} stats={stats} />
{/* Windows on this contract's routes/direction only — tells GL ET when
it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingAlreadyCreated ? ( {bookingAlreadyCreated ? (
<Alert <Alert
color="blue" color="blue"

View File

@@ -359,6 +359,8 @@ export interface BookingWindow {
isOpenNow: boolean; isOpenNow: boolean;
windowOpensAt: string | null; windowOpensAt: string | null;
windowClosesAt: string | null; windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingWindowStatus: string; bookingWindowStatus: string;
bookingCycleNo: number; bookingCycleNo: number;
departureDate: string; departureDate: string;

View File

@@ -0,0 +1,325 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import {
ArrowRight,
CalendarClock,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
const INK = "#10202F";
const MUTED = "#6B7C8E";
const BORDER = "#E6ECF2";
/** All window times are communicated in East Africa Time. */
const TZ = "Africa/Addis_Ababa";
/** Cards visible per carousel page. */
const PER_PAGE = 3;
function fmtDay(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
weekday: "short",
day: "numeric",
month: "short",
timeZone: TZ,
});
}
function fmtTime(iso: string): string {
return new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: TZ,
});
}
/** "Thu, 10 Jul · 08:00 11:00 EAT" (or a phase label when times are unset). */
function windowLabel(w: MyBookingWindow): string {
if (w.windowOpensAt && w.windowClosesAt) {
return `${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} ${fmtTime(
w.windowClosesAt,
)} EAT`;
}
if (w.windowOpensAt) {
return `Opens ${fmtDay(w.windowOpensAt)} · ${fmtTime(w.windowOpensAt)} EAT`;
}
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
}
/**
* The countdown for whichever phase the window is currently in, mirroring the
* home dashboard's Booking Windows card. `expiredText` names the NEXT step so a
* deadline that lapses between refetches announces what comes next rather than
* the bare "Expired".
*/
function phaseCountdown(
w: MyBookingWindow,
): { label: string; deadline: string; expiredText: string } | null {
switch (w.windowPhase) {
case "PRE_WINDOW":
return w.windowOpensAt
? {
label: "Booking opens in",
deadline: w.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return w.windowClosesAt
? {
label: "Window closes in",
deadline: w.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return w.docReviewEndsAt
? {
label: "Document review ends in",
deadline: w.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return w.paymentPhaseEndsAt
? {
label: "Payment due in",
deadline: w.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
}
}
/** Drop windows whose booking window (or the train itself) has already passed. */
function isPast(w: MyBookingWindow): boolean {
const now = Date.now();
const closes = w.windowClosesAt ? new Date(w.windowClosesAt).getTime() : null;
const departs = w.departureDate ? new Date(w.departureDate).getTime() : null;
// Still live while in a post-close staff phase (doc review / payment).
if (w.windowPhase === "DOC_REVIEW" || w.windowPhase === "PAYMENT") return false;
if (departs != null && departs <= now) return true;
if (closes != null && closes <= now) return true;
return false;
}
function WindowCard({ w }: { w: MyBookingWindow }) {
const cd = phaseCountdown(w);
const open = w.isOpenNow;
const isImport = w.direction === "IMPORT";
return (
<Box
p="md"
style={{
borderRadius: 14,
height: "100%",
border: `1px solid ${open ? "#CDEBDD" : BORDER}`,
background: open
? "linear-gradient(160deg, #F4FBF7 0%, #FFFFFF 85%)"
: "#FFFFFF",
boxShadow: open ? "0 2px 10px rgba(10,111,77,0.10)" : "none",
}}
>
<Stack gap={8} h="100%" justify="space-between">
<Box>
<Group justify="space-between" wrap="nowrap" gap={8}>
{w.direction ? (
<Badge
variant="light"
color={isImport ? "blue" : "teal"}
radius="sm"
size="sm"
>
{isImport ? "Import" : "Export"}
</Badge>
) : (
<span />
)}
<Badge
variant={open ? "filled" : "light"}
color={open ? "edr-green" : "gray"}
radius="sm"
size="sm"
>
{open
? "Open now"
: (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ")}
</Badge>
</Group>
<Group gap={6} wrap="nowrap" mt={10}>
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.origin ?? "—"}
</Text>
<ArrowRight size={14} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={15} fw={700} style={{ color: INK }} truncate>
{w.destination ?? "—"}
</Text>
</Group>
<Group gap={6} wrap="nowrap" mt={8}>
<CalendarClock size={13} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={12} style={{ color: MUTED }} truncate>
{windowLabel(w)}
</Text>
</Group>
{w.departureDate ? (
<Text fz={12} style={{ color: MUTED }}>
Departs {fmtDay(w.departureDate)}
</Text>
) : null}
</Box>
{cd ? (
<Box
px={10}
py={6}
style={{
borderRadius: 10,
background: open ? "rgba(10,111,77,0.08)" : "#F8FAFC",
}}
>
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="xs"
/>
</Box>
) : null}
</Stack>
</Box>
);
}
interface ContractBookingWindowsSectionProps {
/** Windows already scoped to this contract's routes/direction by the API. */
windows: MyBookingWindow[];
isLoading: boolean;
}
/**
* Booking windows on THIS contract's routes only (the API filters by the
* contract's route lanes, which also pins the import/export direction) — the
* contract-scoped counterpart of the home dashboard's all-lanes Booking Windows
* card. Paged three cards at a time; hidden when nothing is announced.
*/
export function ContractBookingWindowsSection({
windows,
isLoading,
}: ContractBookingWindowsSectionProps) {
const [page, setPage] = useState(0);
const sorted = useMemo(() => {
const rows = windows.filter(
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
);
// Open lanes first, then by opening time.
return rows.sort((a, b) => {
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
if (openDiff !== 0) return openDiff;
const at = a.windowOpensAt ? new Date(a.windowOpensAt).getTime() : Infinity;
const bt = b.windowOpensAt ? new Date(b.windowOpensAt).getTime() : Infinity;
return at - bt;
});
}, [windows]);
const pageCount = Math.max(1, Math.ceil(sorted.length / PER_PAGE));
const safePage = Math.min(page, pageCount - 1);
const visible = sorted.slice(
safePage * PER_PAGE,
safePage * PER_PAGE + PER_PAGE,
);
if (!isLoading && sorted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<CalendarClock size={18} color={MUTED} />
<Box>
<Text fw={700} fz={16} style={{ color: INK }}>
Booking windows
</Text>
<Text fz={13} style={{ color: MUTED }}>
Windows on this contract&apos;s routes (EAT)
</Text>
</Box>
</Group>
{pageCount > 1 ? (
<Group gap={8} wrap="nowrap">
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Previous windows"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={18} />
</ActionIcon>
<Group gap={5} wrap="nowrap">
{Array.from({ length: pageCount }, (_, i) => (
<Box
key={i}
onClick={() => setPage(i)}
style={{
width: i === safePage ? 18 : 7,
height: 7,
borderRadius: 999,
cursor: "pointer",
background: i === safePage ? "#0A6F4D" : "#D8E2EB",
transition: "width 200ms ease, background 200ms ease",
}}
/>
))}
</Group>
<ActionIcon
variant="default"
radius="xl"
size="lg"
aria-label="Next windows"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRight size={18} />
</ActionIcon>
</Group>
) : null}
</Group>
{isLoading ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={150} radius="md" />
))}
</SimpleGrid>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
)}
</Paper>
);
}

View File

@@ -64,6 +64,7 @@ import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/Clearanc
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action"; import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window"; import { closedWindowMessage, hasOpenWindow } from "./booking-window";
import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection";
import { import {
BORDER, BORDER,
ContractStatusBadge, ContractStatusBadge,
@@ -200,7 +201,7 @@ export default function ContractDetailPage() {
// Booking windows for this contract's routes — gates the direct "New shipment // Booking windows for this contract's routes — gates the direct "New shipment
// booking" entry so the customer only sees it while a window is open. // booking" entry so the customer only sees it while a window is open.
// Refetched every minute so "Open now" flips without a manual reload. // Refetched every minute so "Open now" flips without a manual reload.
const { data: bookingWindows = [] } = useQuery({ const { data: bookingWindows = [], isLoading: windowsLoading } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({ ...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! }, input: { contractId: id! },
refetchInterval: 60_000, refetchInterval: 60_000,
@@ -515,6 +516,16 @@ export default function ContractDetailPage() {
</SimpleGrid> </SimpleGrid>
</Paper> </Paper>
{/* Booking windows on this contract's routes/direction only (the API
filters by the contract's lanes). Intercity contracts aren't
window-gated, so nothing is shown for them. */}
{contract.tradeDirection !== "DOMESTIC" && (
<ContractBookingWindowsSection
windows={bookingWindows}
isLoading={windowsLoading}
/>
)}
{/* Tabs: Details · Documents · Bookings (pill style, like the {/* Tabs: Details · Documents · Bookings (pill style, like the
backoffice booking-requests page; each tab shows a count badge). */} backoffice booking-requests page; each tab shows a count badge). */}
<Tabs <Tabs

View File

@@ -662,7 +662,9 @@ export default function NewContractPage({
</Title> </Title>
<Text size="sm" c="edr-muted" mt={4}> <Text size="sm" c="edr-muted" mt={4}>
{isEdit {isEdit
? editContract?.status === "CHANGES_REQUESTED"
? "Update your contract details and documents, then resubmit it for EDR staff review." ? "Update your contract details and documents, then resubmit it for EDR staff review."
: "Update your draft contract details and documents, then submit it for EDR staff review."
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."} : "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
</Text> </Text>
</Box> </Box>
@@ -685,7 +687,7 @@ export default function NewContractPage({
onSubmit={(e) => e.preventDefault()} onSubmit={(e) => e.preventDefault()}
> >
<Box flex={1} p="24px"> <Box flex={1} p="24px">
{isEdit && ( {isEdit && editContract?.status === "CHANGES_REQUESTED" && (
<Alert <Alert
color="orange" color="orange"
radius="lg" radius="lg"