mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
add booking window to gl
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Accordion,
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -24,9 +23,7 @@ import {
|
||||
Boxes,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ClipboardCheck,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileSignature,
|
||||
Hourglass,
|
||||
@@ -431,101 +428,148 @@ function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
|
||||
}
|
||||
|
||||
/** "05 Jun 2026 · 06:00 – 09:00 EAT" → "06:00 – 09:00 EAT" (date lives in the day header). */
|
||||
function timeLabelOf(label: string): string {
|
||||
const idx = label.indexOf("·");
|
||||
return idx >= 0 ? label.slice(idx + 1).trim() : label;
|
||||
}
|
||||
|
||||
const EAT_TZ = "Africa/Addis_Ababa";
|
||||
const dateKeyFmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: EAT_TZ,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
const dateLabelFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: EAT_TZ,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
});
|
||||
const timeFmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: EAT_TZ,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
|
||||
function windowDateKey(w: BatchWindowGroup): string {
|
||||
if (w.date) return w.date;
|
||||
if (w.start) return dateKeyFmt.format(new Date(w.start));
|
||||
return "undated";
|
||||
interface ScheduleWindow {
|
||||
windowPhase: BatchBoardScheduleDetail["windowPhase"];
|
||||
bookingWindowStatus: string;
|
||||
windowOpensAt: string | null;
|
||||
windowClosesAt: string | null;
|
||||
docReviewEndsAt: string | null;
|
||||
paymentPhaseEndsAt: string | null;
|
||||
bookingCycleNo?: number;
|
||||
}
|
||||
|
||||
/** Human day label for a window — prefers the API field, falls back to `start`. */
|
||||
function windowDateLabel(w: BatchWindowGroup): string {
|
||||
if (w.dateLabel) return w.dateLabel;
|
||||
if (w.start) return dateLabelFmt.format(new Date(w.start));
|
||||
return "Undated";
|
||||
/**
|
||||
* Deadline + label for the phase the schedule's booking window is currently in —
|
||||
* the SAME phases the customer sees on the portal: pre-window (opens) → open
|
||||
* (closes) → document review → payment. `expiredText` names the next step so a
|
||||
* lapsed deadline reads as a handover, not a bare "Expired".
|
||||
*/
|
||||
function windowPhaseCountdown(
|
||||
w: ScheduleWindow,
|
||||
): { 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 window ends in", deadline: w.paymentPhaseEndsAt, expiredText: "Payment window closing…" }
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
(b) => b.allocationStatus === "FAILED" || b.allocationStatus === "DEFERRED",
|
||||
/** One phase row: label + its clock time (or "—" when unset). */
|
||||
function PhaseTimeRow({
|
||||
label,
|
||||
iso,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
iso: string | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" gap="sm" wrap="nowrap">
|
||||
<Text size="sm" fw={active ? 700 : 500} c={active ? "edr-green.7" : "dimmed"}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={active ? 700 : 500} c={active ? "dark" : "dimmed"}>
|
||||
{iso ? `${timeFmt.format(new Date(iso))} EAT` : "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The schedule's REAL booking window — the exact same window the customer sees on
|
||||
* the portal (frozen open/close from the schedule's own snapshot + the post-close
|
||||
* document-review and payment phases), with a live countdown to the current phase.
|
||||
* Replaces the old theoretical "3-hour windows across every day" projection.
|
||||
*/
|
||||
function ScheduleWindowPanel({ window: w }: { window: ScheduleWindow }) {
|
||||
const phase = w.windowPhase;
|
||||
const cd = windowPhaseCountdown(w);
|
||||
const open = phase === "OPEN" && w.bookingWindowStatus === "OPEN";
|
||||
|
||||
const openDay = w.windowOpensAt
|
||||
? dateLabelFmt.format(new Date(w.windowOpensAt))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Accordion.Item value={window.key}>
|
||||
<Accordion.Control>
|
||||
<Group justify="space-between" wrap="nowrap" pr="md" gap="sm">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
background: total ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||||
border: total
|
||||
? "1px solid #FBD171"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
color: total ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
<Clock size={16} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{timeLabelOf(window.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total
|
||||
? `${total} booking${total === 1 ? "" : "s"}`
|
||||
: "Empty window"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={window.counts} />
|
||||
</Group>
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="md"
|
||||
mt="md"
|
||||
style={{
|
||||
borderColor: open
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
background: open ? "var(--mantine-color-edr-green-0)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" mb="sm">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{phase ? (
|
||||
<WindowPhasePill phase={phase} cycleNo={w.bookingCycleNo} />
|
||||
) : null}
|
||||
<WindowStatusPill status={w.bookingWindowStatus} />
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<BookingTable bookings={window.bookings} />
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
{openDay ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Booking day · {openDay}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{cd ? (
|
||||
<Box mb="sm">
|
||||
<CountdownTimer
|
||||
deadline={cd.deadline}
|
||||
label={cd.label}
|
||||
expiredText={cd.expiredText}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6}>
|
||||
<PhaseTimeRow label="Window opens" iso={w.windowOpensAt} active={phase === "PRE_WINDOW"} />
|
||||
<PhaseTimeRow label="Window closes" iso={w.windowClosesAt} active={phase === "OPEN"} />
|
||||
<PhaseTimeRow label="Document review ends" iso={w.docReviewEndsAt} active={phase === "DOC_REVIEW"} />
|
||||
<PhaseTimeRow label="Payment window ends" iso={w.paymentPhaseEndsAt} active={phase === "PAYMENT"} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** EAT calendar date key for a window — prefers the API field, falls back to `start`. */
|
||||
|
||||
export default function BatchScheduleDetailPage() {
|
||||
const { scheduleId } = useParams<{ scheduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -577,6 +621,34 @@ export default function BatchScheduleDetailPage() {
|
||||
return [...byId.values()];
|
||||
}, [data]);
|
||||
|
||||
// All bookings that fall inside the schedule's booking window (every window
|
||||
// cycle, flattened) — the window is one booking day, so these belong to the
|
||||
// single window panel above.
|
||||
const windowBookings = useMemo(
|
||||
() => (data?.windows ?? []).flatMap((w) => w.bookings),
|
||||
[data?.windows],
|
||||
);
|
||||
|
||||
const windowCounts = useMemo(() => {
|
||||
const counts = {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
};
|
||||
for (const b of windowBookings) {
|
||||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||
else if (b.state === "READY") counts.ready += 1;
|
||||
else if (b.state === "WAITING") counts.waiting += 1;
|
||||
else if (b.state === "EXPIRED") counts.expired += 1;
|
||||
else counts.pendingContract += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [windowBookings]);
|
||||
|
||||
// Batch bookings by state for the composition side panel (payment / expired lists).
|
||||
const batchBookings = useMemo(() => {
|
||||
const all = allBookings;
|
||||
@@ -591,102 +663,10 @@ export default function BatchScheduleDetailPage() {
|
||||
[data?.status],
|
||||
);
|
||||
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const byDate = new Map<
|
||||
string,
|
||||
{
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
windows: BatchWindowGroup[];
|
||||
totalBookings: number;
|
||||
counts: BatchWindowGroup["counts"];
|
||||
hasIssues: boolean;
|
||||
}
|
||||
>();
|
||||
for (const w of data.windows) {
|
||||
const dateKey = windowDateKey(w);
|
||||
let group = byDate.get(dateKey);
|
||||
if (!group) {
|
||||
group = {
|
||||
date: dateKey,
|
||||
dateLabel: windowDateLabel(w),
|
||||
windows: [],
|
||||
totalBookings: 0,
|
||||
counts: {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
},
|
||||
hasIssues: false,
|
||||
};
|
||||
byDate.set(dateKey, group);
|
||||
}
|
||||
group.windows.push(w);
|
||||
group.totalBookings += w.bookings.length;
|
||||
group.counts.allocated += w.counts.allocated;
|
||||
group.counts.selectedForBatch += w.counts.selectedForBatch;
|
||||
group.counts.ready += w.counts.ready;
|
||||
group.counts.waiting += w.counts.waiting;
|
||||
group.counts.expired += w.counts.expired;
|
||||
group.counts.pendingContract += w.counts.pendingContract;
|
||||
group.hasIssues =
|
||||
group.hasIssues ||
|
||||
w.bookings.some(
|
||||
(b) =>
|
||||
b.allocationStatus === "FAILED" ||
|
||||
b.allocationStatus === "DEFERRED",
|
||||
);
|
||||
}
|
||||
return [...byDate.values()];
|
||||
}, [data]);
|
||||
|
||||
// Windows with bookings open by default (inside an expanded day).
|
||||
const openWindowKeys = useMemo(
|
||||
() =>
|
||||
data
|
||||
? data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const todayEat = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date()),
|
||||
[],
|
||||
);
|
||||
|
||||
// Date-stepper: which day is currently shown. Default to today, else the first
|
||||
// day with bookings, else the first day. Keep the selection if still valid.
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string | null>("overview");
|
||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!dayGroups.length) return;
|
||||
if (selectedDate && dayGroups.some((d) => d.date === selectedDate)) return;
|
||||
const preferred =
|
||||
dayGroups.find((d) => d.date === todayEat) ??
|
||||
dayGroups.find((d) => d.totalBookings > 0) ??
|
||||
dayGroups[0];
|
||||
setSelectedDate(preferred.date);
|
||||
}, [dayGroups, selectedDate, todayEat]);
|
||||
|
||||
const selectedIndex = Math.max(
|
||||
0,
|
||||
dayGroups.findIndex((d) => d.date === selectedDate),
|
||||
);
|
||||
const selectedDay = dayGroups[selectedIndex];
|
||||
|
||||
const handleCompleteDocReview = () => {
|
||||
completeDocReview
|
||||
@@ -1012,137 +992,30 @@ export default function BatchScheduleDetailPage() {
|
||||
<Clock size={19} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Title order={4}>Booking window (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
3-hour windows for every day from when the booking window
|
||||
opened through the departure date. Bookings appear under the
|
||||
date their contract was signed — open a day to see its
|
||||
windows.
|
||||
The schedule's real booking window — the same window and
|
||||
phase timings the customer sees on the portal. Bookings in
|
||||
the window are listed below.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{dayGroups.length && selectedDay ? (
|
||||
<>
|
||||
{/* Date stepper — page back/forward through each day in the range */}
|
||||
<Group
|
||||
justify="center"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
mt="md"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Previous day"
|
||||
disabled={selectedIndex <= 0}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex - 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</ActionIcon>
|
||||
<ScheduleWindowPanel window={data} />
|
||||
|
||||
<Paper
|
||||
withBorder
|
||||
radius="xl"
|
||||
px="xl"
|
||||
py="xs"
|
||||
style={{
|
||||
flex: 1,
|
||||
maxWidth: 360,
|
||||
textAlign: "center",
|
||||
background: selectedDay.totalBookings
|
||||
? "#FEF1D5"
|
||||
: "white",
|
||||
borderColor: selectedDay.totalBookings
|
||||
? "#FBD171"
|
||||
: "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<Group justify="center" gap={8} wrap="nowrap">
|
||||
<CalendarDays size={15} color="#B26C09" />
|
||||
<Text
|
||||
fw={800}
|
||||
style={{
|
||||
color: selectedDay.totalBookings
|
||||
? "#8A5304"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{selectedDay.dateLabel}
|
||||
</Text>
|
||||
{selectedDay.date === todayEat ? (
|
||||
<Badge size="xs" variant="light" color="#F2A516">
|
||||
Today
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{selectedDay.totalBookings
|
||||
? `${selectedDay.totalBookings} booking${selectedDay.totalBookings === 1 ? "" : "s"} · ${selectedDay.windows.length} windows`
|
||||
: `${selectedDay.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="#F2A516"
|
||||
size="xl"
|
||||
radius="xl"
|
||||
aria-label="Next day"
|
||||
disabled={selectedIndex >= dayGroups.length - 1}
|
||||
onClick={() =>
|
||||
setSelectedDate(
|
||||
dayGroups[selectedIndex + 1]?.date ?? null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group justify="space-between" align="center" mt="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Day {selectedIndex + 1} of {dayGroups.length}
|
||||
{windowBookings.length ? (
|
||||
<Box mt="lg">
|
||||
<Group justify="space-between" align="center" mb="sm">
|
||||
<Text fw={700} size="sm">
|
||||
Bookings in this window
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{selectedDay.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={selectedDay.counts} />
|
||||
</Group>
|
||||
<WindowCountChips counts={windowCounts} />
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
key={selectedDay.date}
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{selectedDay.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</>
|
||||
<BookingTable bookings={windowBookings} />
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No batch windows for this schedule.
|
||||
No bookings in this window yet.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user