mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
Enhance contract management by adding booking windows section and updating invoice logic for offloaded cargo
This commit is contained in:
@@ -395,14 +395,20 @@ 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) {
|
||||||
throw new BadRequestException(
|
const gatepass = await this.gatepassForBooking(bookingId);
|
||||||
'Cargo must be offloaded before the final invoice can be raised.',
|
if (!gatepass.granted) {
|
||||||
);
|
throw new BadRequestException(
|
||||||
|
'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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
/**
|
||||||
|
* Scope the card to one contract: only windows on that contract's routes
|
||||||
|
* (and therefore its import/export direction) are shown. Omit for the
|
||||||
|
* all-lanes staff feed on the clearance queue.
|
||||||
|
*/
|
||||||
|
contractId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All announced booking windows (import cycles + export FCFS) across every lane,
|
* Announced booking windows (import cycles + export FCFS) as a paged carousel —
|
||||||
* shown to GL ET on the clearance queue as a paged carousel — three lanes per
|
* three lanes per page, arrows to flip. Without `contractId` it shows every
|
||||||
* page, arrows to flip. Mirrors the customer's portal "Booking Windows" card.
|
* lane (GL ET clearance queue); with `contractId` it shows only the windows
|
||||||
* Hidden when nothing is pending.
|
* 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() {
|
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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
? "Update your contract details and documents, then resubmit it for EDR staff review."
|
? editContract?.status === "CHANGES_REQUESTED"
|
||||||
|
? "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"
|
||||||
|
|||||||
Reference in New Issue
Block a user