// Bookings.jsx — date-bucketed (Yesterday/Today/Tomorrow/Day After) + category-grouped + status dots
// Replaces WorkOrders.jsx as the primary booking list
const DATE_TABS = [
{ id: 'yesterday', label: 'Yesterday', date: addDaysISO(TODAY, -1) },
{ id: 'today', label: 'Today', date: TODAY },
{ id: 'tomorrow', label: 'Tomorrow', date: addDaysISO(TODAY, 1) },
{ id: 'dayafter', label: 'Day After Tmrw',date: addDaysISO(TODAY, 2) },
{ id: 'plus3', label: '+3 Days', date: addDaysISO(TODAY, 3) },
];
function fmtDateLong(iso) {
const d = new Date(iso + 'T00:00:00');
return d.toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' });
}
function machineStatusFor(machineRef) {
// machineRef looks like "PLC-001 Tadano GR-500EX"
const id = (machineRef || '').split(' ')[0];
const m = MACHINES.find(x => x.id === id);
return m ? m.status : 'unavailable';
}
const MSTATUS = {
available: { color: 'green', label: 'Available' },
occupied: { color: 'red', label: 'Booked' },
unavailable: { color: 'gray', label: 'Not available' },
};
function BookingsPage({ role, setPage }) {
const [activeTab, setActiveTab] = React.useState('today');
const [swapFrom, setSwapFrom] = React.useState(null); // booking object
const [modifyTarget, setModifyTarget] = React.useState(null); // { machine, booking } object
const [, forceRender] = React.useReducer(x => x + 1, 0);
const applySwap = (fromId, toId, fields) => {
const a = BOOKINGS.find(x => x.id === fromId);
const b = BOOKINGS.find(x => x.id === toId);
if (!a || !b) return;
if (fields.operator) { const t = a.operator; a.operator = b.operator; b.operator = t; }
if (fields.rigger) { const t = a.rigger; a.rigger = b.rigger; b.rigger = t; }
if (fields.machine) {
['machine','plate','category','tonnage'].forEach(k => {
const t = a[k]; a[k] = b[k]; b[k] = t;
});
}
forceRender();
};
const applyDelete = (bookingId) => {
const i = BOOKINGS.findIndex(x => x.id === bookingId);
if (i >= 0) BOOKINGS.splice(i, 1);
forceRender();
};
const applyModify = (machineId, bookingId, patch) => {
if (patch.machineStatus) {
const m = MACHINES.find(x => x.id === machineId);
if (m) m.status = patch.machineStatus;
}
if (bookingId) {
const b = BOOKINGS.find(x => x.id === bookingId);
if (b) {
['customer','contact','phone','location','start','end','shift'].forEach(k => {
if (patch[k] !== undefined) b[k] = patch[k];
});
}
}
forceRender();
};
// Per-tab availability counts: how many machines are AVAILABLE (not booked, not in workshop) per category
const tabCounts = {};
DATE_TABS.forEach(t => {
const bookedIds = new Set(BOOKINGS.filter(b => b.date === t.date).map(b => (b.machine || '').split(' ')[0]));
const avail = MACHINES.filter(m => m.status !== 'unavailable' && !bookedIds.has(m.id));
tabCounts[t.id] = {
C: avail.filter(m => m.category === 'Mobile Crane').length,
S: avail.filter(m => m.category === 'Skylift').length,
};
});
const activeDate = DATE_TABS.find(t => t.id === activeTab).date;
const bookingsOnDate = BOOKINGS.filter(b => b.date === activeDate);
// Build slot rows per machine per day. Each machine can produce 1–2 rows:
// - Full-day booking → 1 row "Booked"
// - Morning-only booking → 2 rows: "Booked" (8am–12pm) + "Available" (1pm–5pm)
// - Afternoon-only booking → 2 rows: "Available" (8am–12pm) + "Booked" (1pm–5pm)
// - No booking → 1 row "Available"
// - Workshop / leave → 1 row "Not available"
// This way half-day bookings automatically surface the remaining free half.
const buildRowsForMachine = (m) => {
if (m.status === 'unavailable') {
return [{
machine: m, booking: null,
day: { color: 'gray', label: 'Not available', kind: 'unavailable' },
slot: null,
}];
}
const dayBookings = bookingsOnDate.filter(b => (b.machine || '').split(' ')[0] === m.id);
if (dayBookings.length === 0) {
return [{
machine: m, booking: null,
day: { color: 'green', label: 'Available', kind: 'available' },
slot: null,
}];
}
// Has at least one booking — figure out which shifts are taken
const morn = dayBookings.find(b => b.shift === 'morning');
const aft = dayBookings.find(b => b.shift === 'afternoon');
const custom = dayBookings.find(b => b.shift === 'custom');
// Treat custom as full-day occupation (most common case)
if (custom) {
return dayBookings.map(b => ({
machine: m, booking: b,
day: { color: 'red', label: 'Booked', kind: 'occupied' },
slot: null,
}));
}
const rows = [];
if (morn) {
rows.push({ machine: m, booking: morn,
day: { color: 'red', label: 'Booked', kind: 'occupied' }, slot: 'morning' });
} else {
rows.push({ machine: m, booking: null,
day: { color: 'green', label: 'Available', kind: 'available' }, slot: 'morning' });
}
if (aft) {
rows.push({ machine: m, booking: aft,
day: { color: 'red', label: 'Booked', kind: 'occupied' }, slot: 'afternoon' });
} else {
rows.push({ machine: m, booking: null,
day: { color: 'green', label: 'Available', kind: 'available' }, slot: 'afternoon' });
}
return rows;
};
// Sort: available first, then booked, then not available (machine-level)
const statusOrder = { available: 0, occupied: 1, unavailable: 2 };
const machineRank = (m) => {
if (m.status === 'unavailable') return 2;
const has = bookingsOnDate.some(b => (b.machine || '').split(' ')[0] === m.id);
return has ? 1 : 0;
};
const sortedMachines = [...MACHINES].sort((a, b) => machineRank(a) - machineRank(b));
// Group by category — every machine appears every day, possibly with 2 rows
const grouped = CATEGORIES.map(cat => ({
cat,
items: sortedMachines.filter(m => m.category === cat.name).flatMap(buildRowsForMachine),
})).filter(g => g.items.length > 0);
const isAdmin = role === 'Admin';
const isMgmt = role === 'Management';
return (
{/* Sticky header — booking-status legend + action bar + date tabs */}
{/* Action bar */}
Booking status:
Available
Booked
Not available
{isAdmin ? (
<>
{
// Generate availability for all machines for next 14 days starting today.
// In a real backend this writes "available" slot records; here we just
// confirm the sweep ran successfully against current data.
const todayD = new Date(TODAY + 'T00:00:00');
const days = [];
for (let i = 0; i < 14; i++) {
const d = new Date(todayD); d.setDate(d.getDate() + i);
days.push(d.toISOString().slice(0, 10));
}
let totalSlots = 0;
let bookedSlots = 0;
MACHINES.forEach(m => {
days.forEach(d => {
totalSlots++;
if (BOOKINGS.some(b => b.date === d && (b.machine || '').split(' ')[0] === m.id)) bookedSlots++;
});
});
const free = totalSlots - bookedSlots;
alert(`Availability generated.\n\n${MACHINES.length} machines × 14 days = ${totalSlots} slots\n ✓ ${free} available\n ● ${bookedSlots} booked\n\nMissing machine records back-filled into the next 2 weeks.`);
}}>
Create Availability
setPage('create')}>
New Booking
>
) : (
Booking creation: Admin only
)}
{/* Date tabs */}
{DATE_TABS.map(t => (
setActiveTab(t.id)}
>
{t.label}
{fmtDateLong(t.date)}
{tabCounts[t.id].C}C
{tabCounts[t.id].S}S
Available
))}
{/* Column header — frozen with the legend + tabs above the scrolling list */}
Status
Machine / Operator
Company / Contact
Location
Shift
Actions
{/* /Sticky header */}
{/* Category groups — every machine appears every day, with extra rows for half-day slots */}
{grouped.map(g => {
const bookedCount = g.items.filter(x => x.booking).length;
const machineCount = new Set(g.items.map(x => x.machine.id)).size;
return (
{g.cat.name}
{machineCount} machine{machineCount === 1 ? '' : 's'} · {bookedCount} booked
{g.items.map(({ machine: m, booking: b, day: ms, slot }, idx) => {
const muted = { color: 'var(--color-grey-400)', fontStyle: 'italic' };
const slotLabel = slot === 'morning' ? '8am–12pm' : slot === 'afternoon' ? '1pm–5pm' : null;
return (
{
if (!canEdit(role, 'bookings')) return;
if (ms.kind === 'available') {
// Hand off the machine selection to CreateWO via a window-level
// payload — CreateWO picks it up on mount and clears it.
window.__createPrefill = { machineId: m.id, date: activeDate, blank: true };
setPage('create');
return;
}
if (b) {
// Modify an existing booking → open the full Create-Booking
// form in edit mode so the detail fields are identical.
window.__createPrefill = { editBooking: b };
setPage('create');
return;
}
setModifyTarget({ machine: m, booking: b });
}}
style={{cursor: canEdit(role, 'bookings') ? 'pointer' : 'default'}}>
{b ? (
{b.id}
) : (
{m.id}{slot ? ' · ' + (slot === 'morning' ? 'AM' : 'PM') : ''}
)}
{ms.label}
{machineLabel(m)}
{m.id}
{b ? (
{b.operator}
Rigger: {b.rigger}
) : null}
{b ? (
<>
{b.customer}
{b.contact} · {b.phone}
>
) : (
—
)}
{b ? (
{b.location}
) : (
—
)}
{b ? (
<>
{b.start}–{b.end}
{b.shift === 'morning' ? '8am–12pm' : b.shift === 'afternoon' ? '1pm–5pm' : 'Custom'}
>
) : slotLabel ? (
<>
{slotLabel}
Free slot
>
) : (
—
)}
e.stopPropagation()}>
{b ? (
<>
setSwapFrom(b)} title="Swap operator / rigger / machine with another booking">
Switch
{isMgmt ? (
) : null}
>
) : ms.kind === 'available' && isAdmin ? (
{
e.stopPropagation();
window.__createPrefill = { machineId: m.id, date: activeDate, blank: true };
setPage('create');
}} title="Create booking for this machine">
Book
) : null}
);
})}
);
})}
{/* Permission footer hint */}
Create: Admin only
Delete: Management only
Edit: Admin
{/* Switch / swap dialog */}
{swapFrom ? (
setSwapFrom(null)}/>
) : null}
{/* Modify dialog */}
{modifyTarget ? (
setModifyTarget(null)}/>
) : null}
);
}
// Swap dialog — pick another booking to exchange operator/rigger/machine with
function SwapDialog({ from, bookingsOnDate, applySwap, onClose }) {
const others = bookingsOnDate.filter(b => b.id !== from.id);
const [pickId, setPickId] = React.useState(null);
const [whatToSwap, setWhatToSwap] = React.useState({ operator: true, rigger: true, machine: true });
const [confirmed, setConfirmed] = React.useState(false);
const pick = others.find(b => b.id === pickId);
const toggle = (k) => setWhatToSwap({ ...whatToSwap, [k]: !whatToSwap[k] });
const swapList = Object.entries(whatToSwap).filter(([_, v]) => v).map(([k]) => k);
const confirm = () => {
if (!pick || swapList.length === 0) return;
applySwap(from.id, pick.id, whatToSwap);
setConfirmed(true);
setTimeout(onClose, 1400);
};
return (
e.stopPropagation()} style={{maxWidth: 640}}>
Switch / Swap Booking
Exchange operator, rigger, or machine between two bookings on the same day.
{/* From booking */}
From
{from.id} {from.customer}
{bookingMachineLabel(from)}
Op: {from.operator} Rg: {from.rigger}
{/* To picker */}
Swap with
{others.length === 0 ? (
No other bookings on this day to swap with.
) : others.map(b => (
setPickId(b.id)}/>
{b.id}
{b.customer}
{bookingMachineLabel(b)}
Op: {b.operator}
Rg: {b.rigger}
))}
{/* What to swap */}
{pick ? (
<>
What to swap
{['operator', 'rigger', 'machine'].map(k => (
toggle(k)}
style={{textTransform: 'capitalize'}}>
{whatToSwap[k] ? '✓ ' : ''}{k}
))}
{confirmed ? (
Swap applied. Both operators have been notified via push. The change is logged in the audit trail.
) : null}
Preview: {swapList.length === 0 ? 'Select at least one field to swap.' :
<>Will exchange {swapList.join(', ')} between {from.id} and {pick.id}. Operators & riggers affected will receive push notification immediately.>
}
>
) : null}
Cancel
{confirmed ? 'Swapped' : 'Confirm Swap'}
);
}
// Modify dialog — change machine status + booking details (time / customer / location)
function ModifyDialog({ target, applyModify, applyDelete, role, onClose }) {
const { machine: m, booking: b } = target;
const [machineStatus, setMachineStatus] = React.useState(m.status);
const [shift, setShift] = React.useState(b ? b.shift : 'custom');
const [start, setStart] = React.useState(b ? b.start : '');
const [end, setEnd] = React.useState(b ? b.end : '');
const [customer, setCustomer] = React.useState(b ? b.customer : '');
const [contact, setContact] = React.useState(b ? b.contact : '');
const [phone, setPhone] = React.useState(b ? b.phone : '');
const [location, setLocation] = React.useState(b ? b.location : '');
const [saved, setSaved] = React.useState(false);
const setShiftOption = (id) => {
setShift(id);
if (id === 'morning') { setStart('08:00'); setEnd('12:00'); }
if (id === 'afternoon') { setStart('13:00'); setEnd('17:00'); }
};
// "Available" is locked when the booking has ANY input in the order section.
// Logic: if the form has a customer / contact / phone / location filled in,
// you can't flip status to Available because that contradicts having data.
const orderHasInput = !!(b && (customer.trim() || contact.trim() || phone.trim() || location.trim()));
const save = () => {
const patch = { machineStatus };
if (b) {
Object.assign(patch, { customer, contact, phone, location, start, end, shift });
}
applyModify(m.id, b ? b.id : null, patch);
setSaved(true);
setTimeout(onClose, 1000);
};
// Live overlap detection (only relevant when modifying a booking's time).
// Check 3 kinds: machine, operator, rigger.
let conflict = null;
let conflictKind = null;
if (b) {
let c = findBookingOverlap({ plate: m.plate, date: b.date, start, end, excludeId: b.id });
if (c) { conflict = c; conflictKind = 'machine'; }
if (!conflict) {
c = findOperatorOverlap({ operator: b.operator, date: b.date, start, end, excludeId: b.id });
if (c) { conflict = c; conflictKind = 'operator'; }
}
if (!conflict && b.rigger && b.rigger !== '—') {
c = findRiggerOverlap({ rigger: b.rigger, date: b.date, start, end, excludeId: b.id });
if (c) { conflict = c; conflictKind = 'rigger'; }
}
}
return (
e.stopPropagation()} style={{maxWidth: 580}}>
Modify {b ? 'Booking' : 'Machine'}
{m.id} · {machineLabel(m)}
{b ? <> · {b.id}> : null}
{/* Machine status */}
Machine Status
{[
{id: 'available', label: 'Available', pill: 'green'},
{id: 'occupied', label: 'Booked', pill: 'red'},
{id: 'unavailable', label: 'Not available', pill: 'gray'},
].map(opt => {
// Lock BOTH "Available" and "Not available" when the order has input —
// those states contradict the booking actually having data attached.
const lockChoice = orderHasInput && (opt.id === 'available' || opt.id === 'unavailable');
return (
!lockChoice && setMachineStatus(opt.id)}
style={{flex: 1, opacity: lockChoice ? 0.45 : 1, cursor: lockChoice ? 'not-allowed' : 'pointer'}}
title={lockChoice ? 'Cannot change to ' + opt.label + ' — order section has input. Delete the booking to free this slot.' : undefined}>
{opt.label}
);
})}
{orderHasInput ? (
"Available" and "Not available" are locked while order has input. Delete the order to free this slot.
) : null}
{!b && machineStatus === 'occupied' ? (
⚠ Marking as Booked without a booking record creates a manual occupied flag. Prefer creating a real booking via the Book button.
) : null}
{b ? (
<>
{/* Shift / time */}
Shift & Time
{[{id:'morning',l:'8am–12pm'},{id:'afternoon',l:'1pm–5pm'},{id:'custom',l:'Custom'}].map(o => (
setShiftOption(o.id)} style={{flex: 1}}>{o.l}
))}
{/* Customer */}
{/* Location */}
Job Location
setLocation(e.target.value)} placeholder="Site address / plot" style={{width: '100%'}}/>
>
) : null}
{saved ? (
Changes saved · operator notified via push.
) : null}
{conflict && !saved ? (
{conflictKind === 'machine' ? 'Machine time conflict'
: conflictKind === 'operator' ? 'Operator time conflict'
: 'Rigger time conflict'}
{conflictKind === 'machine' ? (
<>Plate
{m.plate} {start}–{end} clashes with
{conflict.id} ({conflict.start}–{conflict.end} · {conflict.customer}).>
) : conflictKind === 'operator' ? (
<>Operator
{b.operator} already on
{conflict.id} ({conflict.start}–{conflict.end} · plate {conflict.plate}).>
) : (
<>Rigger
{b.rigger} already on
{conflict.id} ({conflict.start}–{conflict.end} · plate {conflict.plate}).>
)}
{' '}Adjust the time before saving.
) : null}
{b ? (
{
if (confirm(`Delete booking ${b.id}?\n\nCustomer: ${b.customer}\nMachine: ${b.machine} (plate ${b.plate})\nDate: ${b.date} ${b.start}–${b.end}\n\nThis cannot be undone.`)) {
applyDelete(b.id);
onClose();
}
}}>
Delete Booking
) : null}
Cancel
{saved ? 'Saved' : 'Save Changes'}
);
}
Object.assign(window, { BookingsPage });