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

@@ -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 { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection";
import {
BORDER,
ContractStatusBadge,
@@ -200,7 +201,7 @@ export default function ContractDetailPage() {
// 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.
// 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({
input: { contractId: id! },
refetchInterval: 60_000,
@@ -515,6 +516,16 @@ export default function ContractDetailPage() {
</SimpleGrid>
</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
backoffice booking-requests page; each tab shows a count badge). */}
<Tabs

View File

@@ -662,7 +662,9 @@ export default function NewContractPage({
</Title>
<Text size="sm" c="edr-muted" mt={4}>
{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."}
</Text>
</Box>
@@ -685,7 +687,7 @@ export default function NewContractPage({
onSubmit={(e) => e.preventDefault()}
>
<Box flex={1} p="24px">
{isEdit && (
{isEdit && editContract?.status === "CHANGES_REQUESTED" && (
<Alert
color="orange"
radius="lg"