mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
update window range
This commit is contained in:
@@ -3,6 +3,9 @@ import {
|
||||
listBatchWindowsForDate,
|
||||
listBatchWindowsForBookings,
|
||||
BATCH_WINDOW_START_HOURS,
|
||||
boardWindowForTimestamp,
|
||||
listBoardWindowsForRange,
|
||||
groupBookingsIntoBoardWindows,
|
||||
} from './batch-window.util';
|
||||
|
||||
describe('batch-window.util', () => {
|
||||
@@ -50,3 +53,84 @@ describe('batch-window.util', () => {
|
||||
expect(getBatchWindowForTimestamp(fullyExecutedAt).key).toBe(overnight!.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch-window board windows (midnight-based 3h slots)', () => {
|
||||
it('maps 04:00 EAT to the 03:00–06:00 slot', () => {
|
||||
// 01:00 UTC = 04:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.label).toContain('06:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
expect(w.dateLabel).toContain('11 Jun');
|
||||
});
|
||||
|
||||
it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => {
|
||||
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
|
||||
expect(w.label).toContain('00:00');
|
||||
expect(w.label).toContain('03:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('maps 23:00 EAT to the final 21:00–24:00 slot', () => {
|
||||
// 20:00 UTC = 23:00 EAT on 11 Jun
|
||||
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
|
||||
expect(w.label).toContain('21:00');
|
||||
expect(w.label).toContain('24:00');
|
||||
expect(w.date).toBe('2026-06-11');
|
||||
});
|
||||
|
||||
it('lists a continuous range open→departure clamped at both ends', () => {
|
||||
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-08T11:00:00.000Z');
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
|
||||
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
|
||||
expect(windows).toHaveLength(6 + 8 + 8 + 5);
|
||||
expect(windows[0].date).toBe('2026-06-05');
|
||||
expect(windows[0].label).toContain('06:00');
|
||||
expect(windows[0].label).toContain('09:00');
|
||||
const last = windows[windows.length - 1];
|
||||
expect(last.date).toBe('2026-06-08');
|
||||
expect(last.label).toContain('12:00');
|
||||
expect(last.label).toContain('15:00');
|
||||
// chronological + unique keys
|
||||
const keys = windows.map((w) => w.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('handles a same-day open→departure range', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot)
|
||||
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot)
|
||||
const windows = listBoardWindowsForRange(open, departure);
|
||||
// 06,09,12 = 3 slots
|
||||
expect(windows).toHaveLength(3);
|
||||
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
|
||||
});
|
||||
|
||||
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
|
||||
const open = new Date('2026-06-05T05:00:00.000Z');
|
||||
const departure = new Date('2026-06-06T11:00:00.000Z');
|
||||
const items = [
|
||||
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th
|
||||
{ id: 'b', ts: null }, // pending
|
||||
];
|
||||
const map = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(i) => i.ts,
|
||||
open,
|
||||
departure,
|
||||
'pending-contract',
|
||||
);
|
||||
const pending = map.get('pending-contract');
|
||||
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
|
||||
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
|
||||
expect(withA?.window?.date).toBe('2026-06-05');
|
||||
// empty slots are retained for the UI
|
||||
const emptyCount = [...map.values()].filter(
|
||||
(b) => b.window && b.items.length === 0,
|
||||
).length;
|
||||
expect(emptyCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,154 @@ export function listBatchWindowsForBookings(
|
||||
return [...byKey.values()].sort(compareBatchWindows);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Board-display windows: full-day, midnight-based 3h slots over a date range.
|
||||
// These are used ONLY for the batch-board UI grouping (not persisted, and
|
||||
// independent of the cron intake hours above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */
|
||||
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
|
||||
|
||||
/** A board window carries an EAT calendar date in addition to the slot times. */
|
||||
export interface BoardWindow extends BatchWindow {
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD`. */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun`. */
|
||||
dateLabel: string;
|
||||
}
|
||||
|
||||
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: BATCH_TIMEZONE,
|
||||
});
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
|
||||
function boardWindowFromEatStart(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
startHour: number,
|
||||
): BoardWindow {
|
||||
const start = eatToUtc(year, month, day, startHour);
|
||||
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
|
||||
const end = eatToUtc(year, month, day, endHour);
|
||||
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
|
||||
return {
|
||||
key: start.toISOString(),
|
||||
start,
|
||||
end,
|
||||
label: formatWindowLabel(start, end, endLabel),
|
||||
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
||||
dateLabel: dayLabelFmt.format(start),
|
||||
};
|
||||
}
|
||||
|
||||
/** Which midnight-based 3h EAT slot a timestamp falls in. */
|
||||
export function boardWindowForTimestamp(date: Date): BoardWindow {
|
||||
const { year, month, day, hour } = eatParts(date);
|
||||
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
|
||||
for (const h of BOARD_WINDOW_HOURS) {
|
||||
if (hour >= h) startHour = h;
|
||||
}
|
||||
return boardWindowFromEatStart(year, month, day, startHour);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
|
||||
* clamped to the slot containing `openDate` on the first day and the slot
|
||||
* containing `departureDate` on the last day. Returned in chronological order.
|
||||
*/
|
||||
export function listBoardWindowsForRange(
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
): BoardWindow[] {
|
||||
const startWin = boardWindowForTimestamp(openDate);
|
||||
const endWin = boardWindowForTimestamp(departureDate);
|
||||
// Guard against an inverted range (departure before open).
|
||||
if (endWin.start.getTime() < startWin.start.getTime()) {
|
||||
return [startWin];
|
||||
}
|
||||
|
||||
const windows: BoardWindow[] = [];
|
||||
const seen = new Set<string>();
|
||||
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
|
||||
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
|
||||
let cursor = new Date(eatToUtc(
|
||||
Number(startWin.date.slice(0, 4)),
|
||||
Number(startWin.date.slice(5, 7)),
|
||||
Number(startWin.date.slice(8, 10)),
|
||||
12,
|
||||
));
|
||||
const lastDayMs = eatToUtc(
|
||||
Number(endWin.date.slice(0, 4)),
|
||||
Number(endWin.date.slice(5, 7)),
|
||||
Number(endWin.date.slice(8, 10)),
|
||||
12,
|
||||
).getTime();
|
||||
|
||||
while (cursor.getTime() <= lastDayMs) {
|
||||
const { year, month, day } = eatParts(cursor);
|
||||
for (const h of BOARD_WINDOW_HOURS) {
|
||||
const w = boardWindowFromEatStart(year, month, day, h);
|
||||
if (
|
||||
w.start.getTime() >= startWin.start.getTime() &&
|
||||
w.start.getTime() <= endWin.start.getTime() &&
|
||||
!seen.has(w.key)
|
||||
) {
|
||||
seen.add(w.key);
|
||||
windows.push(w);
|
||||
}
|
||||
}
|
||||
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
windows.sort(compareBatchWindows);
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items into board windows spanning [openDate, departureDate]. Empty
|
||||
* windows are kept so the UI shows every slot. Items whose timestamp falls
|
||||
* outside the range still get their own window (nothing hidden). Items without
|
||||
* a timestamp go to `pendingKey`.
|
||||
*/
|
||||
export function groupBookingsIntoBoardWindows<T>(
|
||||
items: T[],
|
||||
getTimestamp: (item: T) => Date | null | undefined,
|
||||
openDate: Date,
|
||||
departureDate: Date,
|
||||
pendingKey = 'pending-contract',
|
||||
): Map<string, { window: BoardWindow | null; items: T[] }> {
|
||||
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
|
||||
|
||||
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.set(pendingKey, { window: null, items: [] });
|
||||
|
||||
for (const item of items) {
|
||||
const ts = getTimestamp(item);
|
||||
if (!ts) {
|
||||
map.get(pendingKey)!.items.push(item);
|
||||
continue;
|
||||
}
|
||||
const w = boardWindowForTimestamp(ts);
|
||||
if (!map.has(w.key)) {
|
||||
map.set(w.key, { window: w, items: [] });
|
||||
}
|
||||
map.get(w.key)!.items.push(item);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Group items by batch window key; items without a timestamp go to `pendingKey`. */
|
||||
export function groupByBatchWindow<T>(
|
||||
items: T[],
|
||||
|
||||
@@ -19,9 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import {
|
||||
groupByBatchWindow,
|
||||
} from './batch-window.util';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -82,6 +80,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
@@ -401,11 +403,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
const referenceDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupByBatchWindow(
|
||||
// Display windows span the whole booking window: from when it opened
|
||||
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
|
||||
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
referenceDate,
|
||||
openDate,
|
||||
departureDate,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
@@ -437,6 +443,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
date: w.date,
|
||||
dateLabel: w.dateLabel,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
@@ -477,6 +485,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
pendingContract: {
|
||||
key: 'pending-contract',
|
||||
label: 'Pending contract',
|
||||
date: '',
|
||||
dateLabel: '',
|
||||
start: '',
|
||||
end: '',
|
||||
counts: countFor(pendingBookings),
|
||||
|
||||
@@ -318,6 +318,40 @@ 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",
|
||||
});
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
const total = window.bookings.length;
|
||||
const hasIssues = window.bookings.some(
|
||||
@@ -347,7 +381,7 @@ function WindowAccordionItem({ window }: { window: BatchWindowGroup }) {
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{window.label}
|
||||
{timeLabelOf(window.label)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{total ? `${total} booking${total === 1 ? "" : "s"}` : "Empty window"}
|
||||
@@ -459,13 +493,81 @@ export default function BatchScheduleDetailPage() {
|
||||
"CONTAINER",
|
||||
);
|
||||
|
||||
const defaultOpen = useMemo(() => {
|
||||
// Group the flat window list into per-day sections (one per EAT calendar date).
|
||||
const dayGroups = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const withBookings = data.windows.filter((w) => w.bookings.length > 0).map((w) => w.key);
|
||||
if (data.pendingContract.bookings.length) withBookings.push("pending-contract");
|
||||
return withBookings.length ? withBookings : [data.windows[0]?.key].filter(Boolean);
|
||||
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],
|
||||
);
|
||||
|
||||
// Open today's day, any day with bookings, and pending-contract; else the last day.
|
||||
const openDays = useMemo(() => {
|
||||
if (!dayGroups.length && !data?.pendingContract.bookings.length) return [];
|
||||
const todayEat = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Africa/Addis_Ababa",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
const open = dayGroups
|
||||
.filter((d) => d.totalBookings > 0 || d.date === todayEat)
|
||||
.map((d) => d.date);
|
||||
if (!open.length && dayGroups.length) open.push(dayGroups[dayGroups.length - 1].date);
|
||||
if (data?.pendingContract.bookings.length) open.push("pending-contract");
|
||||
return open;
|
||||
}, [dayGroups, data]);
|
||||
|
||||
const handleRunAllocation = () => {
|
||||
runAllocation
|
||||
.mutateAsync()
|
||||
@@ -706,22 +808,84 @@ export default function BatchScheduleDetailPage() {
|
||||
<Box>
|
||||
<Title order={4}>Batch windows (EAT)</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Bookings grouped by contract signing time in 3-hour windows — expand one to
|
||||
see bookings and wagon allocation issues.
|
||||
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.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={defaultOpen}
|
||||
defaultValue={openDays}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
mt="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{data.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
{dayGroups.map((day) => (
|
||||
<Accordion.Item key={day.date} value={day.date}>
|
||||
<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: day.totalBookings ? "#FEF1D5" : "var(--mantine-color-gray-0)",
|
||||
border: day.totalBookings
|
||||
? "1px solid #FBD171"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
color: day.totalBookings ? "#B26C09" : "var(--mantine-color-gray-5)",
|
||||
}}
|
||||
>
|
||||
<CalendarDays size={16} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="sm" truncate>
|
||||
{day.dateLabel}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{day.totalBookings
|
||||
? `${day.totalBookings} booking${day.totalBookings === 1 ? "" : "s"} · ${day.windows.length} windows`
|
||||
: `${day.windows.length} windows · no bookings`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{day.hasIssues ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
leftSection={<AlertTriangle size={10} />}
|
||||
>
|
||||
Issues
|
||||
</Badge>
|
||||
) : null}
|
||||
<WindowCountChips counts={day.counts} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Accordion
|
||||
multiple
|
||||
defaultValue={openWindowKeys}
|
||||
variant="separated"
|
||||
radius="md"
|
||||
className="bb-window-accordion"
|
||||
>
|
||||
{day.windows.map((window) => (
|
||||
<WindowAccordionItem key={window.key} window={window} />
|
||||
))}
|
||||
</Accordion>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
))}
|
||||
{data.pendingContract.bookings.length ? (
|
||||
<Accordion.Item value="pending-contract">
|
||||
|
||||
@@ -247,6 +247,10 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
|
||||
Reference in New Issue
Block a user