import { useState, useEffect, useRef, useCallback } from 'react' import { RefreshCw, Clock } from 'lucide-react' import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, syncTimesheets, getWorkforceShifts, getWorkforceStaff, } from '../api' import type { BookingsData, TimeReqs, StaffMember, GeneralTask, PickupData, RequiredDay, DayData, WfDayData, WfStaffMember, Adjustment, } from '../types' // ── Date helpers ────────────────────────────────────────────────────────────── function todayStr() { const d = new Date() return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } function thisMonday() { const d = new Date() const diff = (d.getDay() + 6) % 7 // days since Monday (Mon=0 … Sun=6) d.setDate(d.getDate() - diff) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } function offsetDate(s: string, days: number) { const [y, m, d] = s.split('-').map(Number) const dt = new Date(y, m - 1, d) dt.setDate(dt.getDate() + days) return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` } function fmtDayHeader(date: string) { const d = new Date(date + 'T00:00:00') return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()] + ' ' + d.getDate() + ' ' + ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()] } function getDayName(date: string) { const d = new Date(date + 'T00:00:00') return ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][(d.getDay() + 6) % 7] } function fullDayName(date: string) { const d = new Date(date + 'T00:00:00') return ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()] } function isWeekend(date: string) { const day = new Date(date + 'T00:00:00').getDay() return day === 0 || day === 6 } function priorLineFor(date: string, d: DayData): string { const hint = d.pickup_hint ?? 0 const lead = d.pickup_lead ?? 0 const late = hint > 0 ? `${hint} room${hint === 1 ? '' : 's'} picked up within ${lead} day${lead === 1 ? '' : 's'} of arrival` : 'no late pickups' return `Last ${fullDayName(date)}: ${d.prior_occ} occ (${d.prior_vac} vac) — ${late}` } function fmtDate(date: string) { return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', }) } function fmtH(n: number) { return n.toFixed(2) + 'h' } // ── Pickup calculation ──────────────────────────────────────────────────────── function getDisplayPickup( pickup: PickupData, catId: string, date: string, occupiedNow: number, totalRooms: number, ): number { const vacant = Math.max(0, totalRooms - occupiedNow) const saved = pickup[catId]?.[date] if (!saved || !saved.count) return 0 const savedBooked = saved.total - saved.count const count = occupiedNow > savedBooked ? Math.max(0, saved.total - occupiedNow) : saved.count return Math.min(count, vacant) } // ── Required hours calculation ──────────────────────────────────────────────── function calcRequired( bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData, generalTasks: GeneralTask[], adjustments: Adjustment[], ): Record { const result: Record = {} for (const date of bookings.dates) { const dayName = getDayName(date) const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0) const adjHrs = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0) result[date] = { booked: 0, pickup: 0, general: genHrs, adjustments: adjHrs, total: genHrs + adjHrs, by_cat: {}, pickup_by_cat: {} } } for (const cat of bookings.categories) { const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 } bookings.dates.forEach((date, di) => { const d = cat.days[date] const occupied = d.stays + d.arrivals const pickupCount = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms) const bookedHrs = (d.departs * (req.depart || 0) + d.stays * (req.stay || 0) + d.arrivals * (req.arrive || 0)) / 60 const pickupArrHrs = pickupCount * (req.arrive || 0) / 60 result[date].by_cat[cat.id] = bookedHrs result[date].pickup_by_cat[cat.id] = (result[date].pickup_by_cat[cat.id] || 0) + pickupArrHrs result[date].booked += bookedHrs result[date].pickup += pickupArrHrs result[date].total += bookedHrs + pickupArrHrs const nextDate = bookings.dates[di + 1] if (nextDate && pickupCount > 0) { const pickupDepHrs = pickupCount * (req.depart || 0) / 60 result[nextDate].pickup_by_cat[cat.id] = (result[nextDate].pickup_by_cat[cat.id] || 0) + pickupDepHrs result[nextDate].pickup += pickupDepHrs result[nextDate].total += pickupDepHrs } }) } return result } // ── Main Planner component ──────────────────────────────────────────────────── export function Planner() { const [bookings, setBookings] = useState(null) const [timeReqs, setTimeReqs] = useState({}) const [staff, setStaff] = useState([]) const [pickup, setPickup] = useState({}) const [generalTasks, setGeneralTasks] = useState([]) const [adjustments, setAdjustments] = useState([]) const [warnOverRed, setWarnOverRed] = useState(4) const [warnOverAmber, setWarnOverAmber] = useState(1) const [warnUnderAmber, setWarnUnderAmber] = useState(1) const [warnUnderRed, setWarnUnderRed] = useState(2) const [wfShifts, setWfShifts] = useState>({}) const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([]) const [syncing, setSyncing] = useState(false) const [timesheetSyncing, setTimesheetSyncing] = useState(false) const [weekStart, setWeekStart] = useState(null) const [lastViewed, setLastViewed] = useState('') const [savedLastReviewed, setSavedLastReviewed] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [saveMsg, setSaveMsg] = useState<{ text: string; err: boolean } | null>(null) const timers = useRef>>({}) const saveMsgTimer = useRef | null>(null) const wfStaffLoaded = useRef(false) const weekStartRef = useRef(null) weekStartRef.current = weekStart // always current, updated every render function debounce(key: string, fn: () => void, delay = 400) { clearTimeout(timers.current[key]) timers.current[key] = setTimeout(fn, delay) } function flash(text: string, err = false) { if (saveMsgTimer.current) clearTimeout(saveMsgTimer.current) setSaveMsg({ text, err }) saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 15000 : 2500) } function stampLastReviewed() { const today = todayStr() if (today === savedLastReviewed) return setSavedLastReviewed(today) putLastReviewed(today).catch(() => {}) } const loadAll = useCallback(async (force = false, lvArg?: string) => { setLoading(true) setError('') const today = todayStr() const ws = weekStartRef.current || thisMonday() const wsEnd = offsetDate(ws, 6) const lv = lvArg !== undefined ? (lvArg || offsetDate(today, -1)) : (lastViewed || savedLastReviewed || offsetDate(today, -1)) try { const [b, cfg, shifts] = await Promise.all([ getBookings(ws, lv, force), getConfig(), getWorkforceShifts(ws, wsEnd).catch(() => ({} as Record)), ]) setBookings(b) setTimeReqs(cfg.time_requirements || {}) setStaff(cfg.staff_data || []) setPickup(cfg.pickup_data || {}) setGeneralTasks(cfg.general_tasks || []) setAdjustments(cfg.adjustments || []) setWarnOverRed(cfg.warn_over_red_hrs ?? 4) setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1) setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1) setWarnUnderRed(cfg.warn_under_red_hrs ?? 2) setWfShifts(shifts) if (cfg.last_reviewed) { setSavedLastReviewed(cfg.last_reviewed) if (!lastViewed) setLastViewed(cfg.last_reviewed) } } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load data') } finally { setLoading(false) } }, [lastViewed, savedLastReviewed]) useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps // Lazily load WF staff list for datalist once any shifts exist useEffect(() => { if (Object.keys(wfShifts).length > 0 && !wfStaffLoaded.current) { wfStaffLoaded.current = true getWorkforceStaff().then(setWfStaff).catch(() => {}) } }, [wfShifts]) // Keepalive save on unload (sendBeacon only supports POST but routes are PUT) useEffect(() => { function keepalivePut(path: string, body: unknown) { fetch(path, { method: 'PUT', keepalive: true, credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } function onUnload() { if (staff.length) keepalivePut('/hk-planner/api/config/staff', { staff_data: staff }) if (Object.keys(pickup).length) keepalivePut('/hk-planner/api/config/pickup', { pickup_data: pickup }) if (adjustments.length) keepalivePut('/hk-planner/api/config/adjustments', { adjustments }) } window.addEventListener('beforeunload', onUnload) return () => window.removeEventListener('beforeunload', onUnload) }, [staff, pickup, adjustments]) function handleWeekOffset(days: number) { const newStart = offsetDate(weekStartRef.current || thisMonday(), days) weekStartRef.current = newStart setWeekStart(newStart) stampLastReviewed() loadAll(true) } function handleWeekToday() { weekStartRef.current = null setWeekStart(null) stampLastReviewed() loadAll(true) } function handleRefresh() { stampLastReviewed() loadAll(true) } // ── Pickup ────────────────────────────────────────────────────────────────── function handlePickupChange(catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record }) { const d = cat.days[date] const occupied = d.stays + d.arrivals const vacant = Math.max(0, cat.total_rooms - occupied) const current = getDisplayPickup(pickup, catId, date, occupied, cat.total_rooms) const newVal = Math.max(0, Math.min(current + delta, vacant)) const next: PickupData = { ...pickup, [catId]: { ...(pickup[catId] || {}), [date]: { count: newVal, total: occupied + newVal } }, } setPickup(next) stampLastReviewed() debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true))) } // ── Adjustments ───────────────────────────────────────────────────────────── function handleAdjustmentsChange(next: Adjustment[]) { setAdjustments(next) debounce('adjustments', () => putAdjustments(next).then(() => flash('Adjustments saved')).catch(e => flash(e.message, true)) ) } function addAdjustmentRow() { handleAdjustmentsChange([...adjustments, { label: '', hours: {} }]) } // ── Staff ──────────────────────────────────────────────────────────────────── function handleStaffChange(next: StaffMember[]) { setStaff(next) stampLastReviewed() debounce('staff', () => putStaff(next).then(() => flash('Staff hours saved')).catch(e => flash(e.message, true))) } function addStaffRow() { handleStaffChange([...staff, { name: '', hours: {} }]) } // ── Workforce sync ─────────────────────────────────────────────────────────── async function syncRota() { if (!bookings) return const start = bookings.dates[0] const end = bookings.dates[bookings.dates.length - 1] setSyncing(true) try { await syncWorkforce(start, end) const shifts = await getWorkforceShifts(start, end) setWfShifts(shifts) flash('Rota synced from Workforce') } catch (e) { flash(e instanceof Error ? e.message : 'Sync failed', true) } finally { setSyncing(false) } } async function syncTimesheetData() { if (!bookings) return const start = bookings.dates[0] const today = todayStr() const end = bookings.dates[bookings.dates.length - 1] > today ? today : bookings.dates[bookings.dates.length - 1] setTimesheetSyncing(true) try { await syncTimesheets(start, end) const shifts = await getWorkforceShifts(start, end) setWfShifts(shifts) flash('Timesheets pulled from Workforce') } catch (e) { flash(e instanceof Error ? e.message : 'Timesheet sync failed', true) } finally { setTimesheetSyncing(false) } } function wfSyncLabel(): string { if (!bookings) return '' const viewDates = bookings.dates.filter(d => wfShifts[d]) if (!viewDates.length) return 'Not synced' const oldest = viewDates.reduce((min, d) => new Date(wfShifts[d].synced_at) < new Date(wfShifts[min].synced_at) ? d : min ) const dt = new Date(wfShifts[oldest].synced_at) const day = dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) const time = dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) const missing = bookings.dates.filter(d => !wfShifts[d]).length return missing ? `Partial sync — from ${day} ${time}` : `From ${day} ${time}` } // ── Required hours (memoised on state changes) ──────────────────────────── const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks, adjustments) : null // ── Render ────────────────────────────────────────────────────────────────── const displayWeekStart = weekStart || thisMonday() return (
{/* Controls */}

Housekeeping Planner

{bookings && (

{fmtDate(bookings.dates[0])} – {fmtDate(bookings.dates[bookings.dates.length - 1])}

)}
{/* Week navigation */} handleWeekOffset(-7)}>‹ Prev Today handleWeekOffset(7)}>Next › { if (e.target.value) { const v = e.target.value; weekStartRef.current = v; setWeekStart(v); stampLastReviewed(); debounce('weekStart', () => loadAll(true), 600) } }} style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }} />
{/* Last-reviewed */} { stampLastReviewed(); loadAll(true) }}>Update to now
{/* Status */} {saveMsg && (
{saveMsg.text}
)} {error && (
Error: {error}
)} {loading && !bookings && (
Loading bookings…
)} {bookings && required && ( <> {/* 1 — Occupancy summary */}
{/* 2 — Required hours */}
{/* 3 — Adjustments */}
+ Add adjustment}>
{/* 4 — Staff rota */}
{wfSyncLabel()} + Add staff
} >
)}
) } // ── Section wrapper ─────────────────────────────────────────────────────────── function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return (

{title}

{action}
{children}
) } function CtrlBtn({ children, onClick, primary }: { children: React.ReactNode; onClick?: () => void; primary?: boolean }) { return ( ) } // ── Summary Table ───────────────────────────────────────────────────────────── interface SummaryProps { bookings: BookingsData pickup: PickupData onPickupChange: (catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record }) => void } function SummaryTable({ bookings, pickup, onPickupChange }: SummaryProps) { const { dates, categories: cats } = bookings return ( {dates.map(d => ( ))} {dates.map(d => ( <> ))} {cats.map((cat, ci) => { const isLast = ci === cats.length - 1 return ( onPickupChange(cat.id, date, delta, cat)} isLast={isLast} /> ) })}
Category{fmtDayHeader(d)}
Rooms D / S / A
) } function CatRows({ cat, dates, pickup, onPickupChange, isLast }: { cat: { id: string; name: string; total_rooms: number; days: Record } dates: string[] pickup: PickupData onPickupChange: (date: string, delta: number) => void isLast: boolean }) { const bbStyle = isLast ? '2px solid var(--card-border)' : undefined return ( {cat.name} {dates.map((date, di) => { const d = cat.days[date] const occupied = d.stays + d.arrivals const vacant = Math.max(0, cat.total_rooms - occupied) const pu = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms) const prevDate = dates[di - 1] const prevOcc = prevDate ? (cat.days[prevDate].stays + cat.days[prevDate].arrivals) : 0 const prevPu = prevDate ? getDisplayPickup(pickup, cat.id, prevDate, prevOcc, cat.total_rooms) : 0 const dNew = d.delta_new || 0 const dCanc = d.delta_cancelled || 0 const wknd = isWeekend(date) const saved = pickup[cat.id]?.[date] const priorLine = priorLineFor(date, d) let occTooltip = '' if (saved && saved.count) { const savedBooked = saved.total - saved.count const changeDesc = occupied > savedBooked ? 'changed' : 'unchanged' const pickupDesc = occupied > savedBooked ? 'decreased' : 'remains' occTooltip = `+${saved.count} pickup set when booked was ${savedBooked} (target ${saved.total}). Booked ${changeDesc}, so pickup ${pickupDesc}.` } occTooltip = (occTooltip ? occTooltip + ' | ' : '') + priorLine return ( <> {/* Left cell: delta ticker + Rooms / vac / pickup controls */} {(dNew > 0 || dCanc > 0) && (
{dNew > 0 && ▲{dNew}} {dCanc > 0 && ▼{dCanc}}
)}
{occupied}{pu > 0 && +{pu}}
{vacant} vac{pu > 0 && ({vacant - pu})}
{pu}
{/* Right cell: D / S / A */}
{d.departs}d{prevPu > 0 && +{prevPu}}
{d.stays}s
{d.arrivals}a{pu > 0 && +{pu}}
) })} ) } function TotalRows({ dates, cats, pickup }: { dates: string[] cats: BookingsData['categories'] pickup: PickupData }) { return ( Total {dates.map((date, di) => { let occupied = 0, vacant = 0, pu = 0, prevPu = 0, departs = 0, stays = 0, arrivals = 0 let dNew = 0, dCanc = 0 for (const cat of cats) { const d = cat.days[date] const occ = d.stays + d.arrivals const vac = Math.max(0, cat.total_rooms - occ) const p = getDisplayPickup(pickup, cat.id, date, occ, cat.total_rooms) const prev = dates[di - 1] const pOcc = prev ? (cat.days[prev].stays + cat.days[prev].arrivals) : 0 const pPu = prev ? getDisplayPickup(pickup, cat.id, prev, pOcc, cat.total_rooms) : 0 occupied += occ; vacant += vac; pu += p; prevPu += pPu departs += d.departs; stays += d.stays; arrivals += d.arrivals dNew += d.delta_new || 0 dCanc += d.delta_cancelled || 0 } const wknd = isWeekend(date) return ( <> {(dNew > 0 || dCanc > 0) && (
{dNew > 0 && ▲{dNew}} {dCanc > 0 && ▼{dCanc}}
)}
{occupied}{pu > 0 && +{pu}}
{vacant} vac{pu > 0 && ({vacant - pu})}
{departs}d{prevPu > 0 && +{prevPu}}
{stays}s
{arrivals}a{pu > 0 && +{pu}}
) })} ) } // ── Required Hours Table ────────────────────────────────────────────────────── function RequiredTable({ bookings, required }: { bookings: BookingsData; required: Record }) { const { dates, categories: cats } = bookings return ( {dates.map(d => )} {cats.map(cat => ( {dates.map(date => { const booked = required[date].by_cat[cat.id] || 0 const pu = required[date].pickup_by_cat[cat.id] || 0 return ( ) })} ))} {dates.map(d => )} {dates.map(d => )} {dates.map(d => )} {dates.some(d => required[d].adjustments !== 0) && ( {dates.map(d => { const adj = required[d].adjustments return ( ) })} )} {dates.map(d => ( ))}
Category{fmtDayHeader(d)}
{cat.name} {booked === 0 && pu === 0 ? '—' : fmtH(booked)} {pu > 0.001 && (+{fmtH(pu)})}
Booked hrs{fmtH(required[d].booked)}
Pickup hrs{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}
Recurring tasks{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}
Adjustments 0 ? '#1a7a4a' : 'var(--text-mid)' }}> {adj === 0 ? '—' : (adj > 0 ? '+' : '') + fmtH(adj)}
Total Required {fmtH(required[d].total)} {required[d].pickup > 0.001 && ( inc {fmtH(required[d].pickup)} pickup )}
) } // ── Staff Table ─────────────────────────────────────────────────────────────── function pivotWfShifts(wfShifts: Record, dates: string[]): WfStaffMember[] { const members: Record = {} for (const date of dates) { const day = wfShifts[date] if (!day) continue for (const s of day.staff) { // Skip in-progress timesheet entries — clocked in but no clock-out time yet if (s.times === '?' && s.hours === 0) continue if (!members[s.id]) members[s.id] = { id: s.id, name: s.name, days: {} } members[s.id].days[date] = { hours: s.hours, times: s.times, status: s.status } } } return Object.values(members).sort((a, b) => a.name.localeCompare(b.name)) } function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, wfShifts, wfStaff }: { bookings: BookingsData staff: StaffMember[] required: Record warnOverRed: number warnOverAmber: number warnUnderAmber: number warnUnderRed: number onChange: (next: StaffMember[]) => void wfShifts: Record wfStaff: { id: string; name: string }[] }) { const { dates } = bookings function setMemberName(i: number, name: string) { const next = staff.map((m, idx) => idx === i ? { ...m, name } : m) onChange(next) } function setMemberHours(i: number, date: string, val: string) { const hrs = parseFloat(val) const next = staff.map((m, idx) => { if (idx !== i) return m const hours = { ...m.hours } if (!isNaN(hrs) && hrs >= 0) hours[date] = hrs else delete hours[date] return { ...m, hours } }) onChange(next) } function removeRow(i: number) { onChange(staff.filter((_, idx) => idx !== i)) } const rotaMembers = pivotWfShifts(wfShifts, dates) return ( {dates.map(d => )} {/* Workforce rota rows — read-only */} {rotaMembers.map(member => ( {dates.map(date => { const shift = member.days[date] const isTimesheet = wfShifts[date]?.source === 'timesheet' const isPending = isTimesheet && shift?.status !== 'APPROVED' return ( ) })} ))} {/* Manual rows */} {wfStaff.length > 0 && ( {wfStaff.map(s => )} {staff.map((member, i) => ( {dates.map(date => ( ))} ))} {(() => { const avail: Record = {} for (const date of dates) { const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0) const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) avail[date] = rotaHrs + manualHrs } return ( <> {dates.map(date => )} {dates.map(date => ( ))} {dates.map(date => ( ))} {dates.map(date => ( ))} {dates.map(date => ( ))} ) })()}
Staff Member{fmtDayHeader(d)}
WF {member.name} {shift ? ( <>
{shift.times}
{isTimesheet && ( )} {fmtH(shift.hours)}
) : }
0 ? 'wf-staff-datalist' : undefined} onChange={e => setMemberName(i, e.target.value)} /> setMemberHours(i, date, e.target.value)} />
Total Available{fmtH(avail[date])}
vs Booked
with Recurring Tasks
with Pickup
with Adjustments
) } function PlainDiffCell({ available, required, warnOverAmber, warnUnderAmber }: { available: number; required: number; warnOverAmber: number; warnUnderAmber: number }) { if (available === 0 && required === 0) return const diff = available - required const icon = diff > warnOverAmber ? '⚠' : diff < -warnUnderAmber ? '✗' : '✓' return {icon} {diff >= 0 ? '+' : ''}{fmtH(diff)} } function DiffCell({ available, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed }: { available: number; required: number warnOverRed: number; warnOverAmber: number; warnUnderAmber: number; warnUnderRed: number }) { if (available === 0 && required === 0) return const diff = available - required if (diff > warnOverRed) return ⚠ {fmtH(diff)} spare if (diff > warnOverAmber) return ⚠ {fmtH(diff)} spare if (diff < -warnUnderRed) return ✗ {fmtH(Math.abs(diff))} short if (diff < -warnUnderAmber) return ✗ {fmtH(Math.abs(diff))} short return ✓ {diff >= 0 ? '+' : ''}{fmtH(diff)} } // ── Adjustments Table ───────────────────────────────────────────────────────── function AdjustmentsTable({ bookings, adjustments, onChange }: { bookings: BookingsData adjustments: Adjustment[] onChange: (next: Adjustment[]) => void }) { const { dates } = bookings function setLabel(i: number, label: string) { onChange(adjustments.map((a, idx) => idx === i ? { ...a, label } : a)) } function setHours(i: number, date: string, val: string) { const hrs = parseFloat(val) onChange(adjustments.map((a, idx) => { if (idx !== i) return a const hours = { ...a.hours } if (!isNaN(hrs)) hours[date] = hrs else delete hours[date] return { ...a, hours } })) } function removeRow(i: number) { onChange(adjustments.filter((_, idx) => idx !== i)) } if (adjustments.length === 0) { return (

No adjustments — use "+ Add adjustment" above to add a one-off hour offset for a specific date.

) } return ( {dates.map(d => )} {adjustments.map((adj, i) => ( {dates.map(date => ( ))} ))} {dates.map(date => { const total = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0) return ( ) })}
Label{fmtDayHeader(d)}
setLabel(i, e.target.value)} /> setHours(i, date, e.target.value)} />
Total 0 ? '#1a7a4a' : 'var(--text-mid)', fontWeight: total !== 0 ? 600 : undefined }}> {total === 0 ? '—' : (total > 0 ? '+' : '') + fmtH(total)}
) }