// 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 ? ( <> ) : ( Booking creation: Admin only )}
{/* Date tabs */}
{DATE_TABS.map(t => ( ))}
{/* 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 ? ( <> {isMgmt ? ( ) : null} ) : ms.kind === 'available' && isAdmin ? ( ) : 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 => ( ))}
{/* What to swap */} {pick ? ( <>
What to swap
{['operator', 'rigger', 'machine'].map(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}
); } // 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 */}
{[ {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 ( ); })}
{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 */}
{[{id:'morning',l:'8am–12pm'},{id:'afternoon',l:'1pm–5pm'},{id:'custom',l:'Custom'}].map(o => ( ))}
Start
setStart(e.target.value)} style={{width: '100%'}}/>
End
setEnd(e.target.value)} style={{width: '100%'}}/>
{/* Customer */}
setCustomer(e.target.value)} placeholder="Company name" style={{width: '100%', marginBottom: 8}}/>
setContact(e.target.value)} placeholder="Contact person"/> setPhone(e.target.value)} placeholder="Phone"/>
{/* 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 ? ( ) : null}
); } Object.assign(window, { BookingsPage });