hk-planner/frontend/src/pages/Planner.tsx
jtricerolph e7975db4a3 Show hours to 2 decimal places (0.75h instead of 0.8h)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 15:43:40 +00:00

918 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import {
getBookings, getConfig, putTimeReq, putStaff, putPickup,
putGeneralTasks, putLastReviewed,
} from '../api'
import type {
BookingsData, TimeReqs, StaffMember, GeneralTask,
PickupData, RequiredDay, DayData,
} 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 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[],
): Record<string, RequiredDay> {
const result: Record<string, RequiredDay> = {}
for (const date of bookings.dates) {
const dayName = getDayName(date)
const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0)
result[date] = { booked: 0, pickup: 0, general: genHrs, total: genHrs, 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<BookingsData | null>(null)
const [timeReqs, setTimeReqs] = useState<TimeReqs>({})
const [staff, setStaff] = useState<StaffMember[]>([])
const [pickup, setPickup] = useState<PickupData>({})
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
const [tolerance, setTolerance] = useState(30)
const [weekStart, setWeekStart] = useState<string | null>(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<Record<string, ReturnType<typeof setTimeout>>>({})
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
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 ? 5000 : 2500)
}
function stampLastReviewed() {
const today = todayStr()
if (today === savedLastReviewed) return
setSavedLastReviewed(today)
putLastReviewed(today).catch(() => {})
}
const loadAll = useCallback(async (force = false) => {
setLoading(true)
setError('')
const today = todayStr()
const ws = weekStart || today
const lv = lastViewed || savedLastReviewed || offsetDate(today, -1)
try {
const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()])
setBookings(b)
setTimeReqs(cfg.time_requirements || {})
setStaff(cfg.staff_data || [])
setPickup(cfg.pickup_data || {})
setGeneralTasks(cfg.general_tasks || [])
setTolerance(cfg.tolerance_minutes ?? 30)
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)
}
}, [weekStart, lastViewed, savedLastReviewed])
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
// Beacon save on unload
useEffect(() => {
function onUnload() {
if (staff.length) {
navigator.sendBeacon('/hk-planner/api/config/staff', JSON.stringify({ staff_data: staff }))
}
if (Object.keys(pickup).length) {
navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup }))
}
if (generalTasks.length) {
navigator.sendBeacon('/hk-planner/api/config/general-tasks', JSON.stringify({ general_tasks: generalTasks }))
}
}
window.addEventListener('beforeunload', onUnload)
return () => window.removeEventListener('beforeunload', onUnload)
}, [staff, pickup, generalTasks])
function handleWeekOffset(days: number) {
const today = todayStr()
const newStart = offsetDate(weekStart || today, days)
setWeekStart(newStart)
stampLastReviewed()
setTimeout(() => loadAll(true), 0)
}
function handleWeekToday() {
setWeekStart(null)
stampLastReviewed()
setTimeout(() => loadAll(true), 0)
}
function handleRefresh() {
stampLastReviewed()
loadAll(true)
}
// ── Pickup ──────────────────────────────────────────────────────────────────
function handlePickupChange(catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record<string, DayData> }) {
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)))
}
// ── Time requirements ───────────────────────────────────────────────────────
function handleTimeReqChange(cat: string, action: string, value: number) {
const next: TimeReqs = {
...timeReqs,
[cat]: { ...(timeReqs[cat] || { depart: 0, stay: 0, arrive: 0 }), [action]: value },
}
setTimeReqs(next)
stampLastReviewed()
debounce(`req-${cat}-${action}`, () =>
putTimeReq(cat, action, value).then(() => flash('Time requirements saved')).catch(e => flash(e.message, true))
)
}
// ── 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: {} }])
}
// ── General tasks ────────────────────────────────────────────────────────────
function handleTasksChange(next: GeneralTask[]) {
setGeneralTasks(next)
stampLastReviewed()
debounce('tasks', () => putGeneralTasks(next).then(() => flash('Tasks saved')).catch(e => flash(e.message, true)))
}
function addTaskRow() {
handleTasksChange([...generalTasks, { name: '', hours: {} }])
}
// ── Required hours (memoised on state changes) ────────────────────────────
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
// ── Render ──────────────────────────────────────────────────────────────────
const today = todayStr()
const displayWeekStart = weekStart || today
return (
<div style={{ padding: '1.5rem', maxWidth: '1600px' }}>
{/* Controls */}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div>
<h1 style={{ fontSize: '1.15rem', fontWeight: 700, color: 'var(--text-dark)' }}>
Housekeeping Planner
</h1>
{bookings && (
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.1rem' }}>
{fmtDate(bookings.dates[0])} {fmtDate(bookings.dates[bookings.dates.length - 1])}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginLeft: 'auto', flexWrap: 'wrap' }}>
{/* Week navigation */}
<CtrlBtn onClick={() => handleWeekOffset(-7)}> Prev</CtrlBtn>
<CtrlBtn onClick={handleWeekToday} primary>Today</CtrlBtn>
<CtrlBtn onClick={() => handleWeekOffset(7)}>Next </CtrlBtn>
<input
type="date"
value={displayWeekStart}
onChange={e => { if (e.target.value) { setWeekStart(e.target.value); stampLastReviewed(); setTimeout(() => loadAll(true), 0) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }}
/>
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
{/* Last-reviewed */}
<label style={{ fontSize: '0.78rem', color: 'var(--text-mid)', display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
<span>Since</span>
<input
type="date"
value={lastViewed}
onChange={e => { if (e.target.value) { setLastViewed(e.target.value); setTimeout(() => loadAll(true), 0) } }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' }}
/>
</label>
<CtrlBtn onClick={() => { stampLastReviewed(); loadAll(true) }}>Update to now</CtrlBtn>
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
<button onClick={handleRefresh} title="Refresh" style={{
background: 'var(--card-bg)', border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.35rem 0.5rem', display: 'flex', alignItems: 'center', gap: '0.3rem',
fontSize: '0.82rem', color: 'var(--text-dark)',
}}>
<RefreshCw size={13} strokeWidth={1.75} style={{ animation: loading ? 'spin 1s linear infinite' : 'none' }} />
</button>
</div>
</div>
{/* Status */}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
{saveMsg && (
<div style={{
display: 'inline-block', marginBottom: '0.75rem', padding: '0.35rem 0.75rem',
borderRadius: '6px', fontSize: '0.8rem', fontWeight: 500,
background: saveMsg.err ? '#fee2e2' : '#dcfce7',
color: saveMsg.err ? 'var(--danger)' : 'var(--success)',
}}>
{saveMsg.text}
</div>
)}
{error && (
<div style={{
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem',
}}>
Error: {error}
</div>
)}
{loading && !bookings && (
<div style={{ color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }}>
Loading bookings
</div>
)}
{bookings && required && (
<>
{/* 1 — Occupancy summary */}
<Section title="7-Day Occupancy">
<div className="table-scroll">
<SummaryTable bookings={bookings} pickup={pickup} onPickupChange={handlePickupChange} />
</div>
</Section>
{/* 2 — Required hours */}
<Section title="Required Hours">
<div className="table-scroll">
<RequiredTable bookings={bookings} required={required} />
</div>
</Section>
{/* 3 — Staff rota */}
<Section title="Staff Rota" action={<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>}>
<div className="table-scroll">
<StaffTable
bookings={bookings}
staff={staff}
required={required}
tolerance={tolerance}
onChange={handleStaffChange}
/>
</div>
</Section>
{/* 4 — General tasks */}
<Section title="General HK Tasks" action={<CtrlBtn onClick={addTaskRow}>+ Add task</CtrlBtn>}>
<div className="table-scroll">
<GeneralTasksTable tasks={generalTasks} onChange={handleTasksChange} />
</div>
</Section>
{/* 5 — Time requirements */}
<Section title="Time Requirements (minutes per room)">
<div className="table-scroll">
<TimeTable categories={bookings.categories} timeReqs={timeReqs} onChange={handleTimeReqChange} />
</div>
</Section>
</>
)}
</div>
)
}
// ── Section wrapper ───────────────────────────────────────────────────────────
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<div style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.5rem' }}>
<h2 style={{ fontSize: '0.72rem', fontWeight: 700, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{title}
</h2>
{action}
</div>
{children}
</div>
)
}
function CtrlBtn({ children, onClick, primary }: { children: React.ReactNode; onClick?: () => void; primary?: boolean }) {
return (
<button onClick={onClick} style={{
background: primary ? 'var(--hk-green)' : 'var(--card-bg)',
color: primary ? '#fff' : 'var(--text-dark)',
border: `1px solid ${primary ? 'var(--hk-green)' : 'var(--card-border)'}`,
borderRadius: '6px', padding: '0.35rem 0.75rem', fontSize: '0.82rem', fontWeight: 600,
}}>
{children}
</button>
)
}
// ── Summary Table ─────────────────────────────────────────────────────────────
interface SummaryProps {
bookings: BookingsData
pickup: PickupData
onPickupChange: (catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record<string, DayData> }) => void
}
function SummaryTable({ bookings, pickup, onPickupChange }: SummaryProps) {
const { dates, categories: cats } = bookings
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label" rowSpan={2}>Category</th>
{dates.map(d => (
<th key={d} colSpan={2} style={{
borderLeft: '2px solid rgba(255,255,255,0.3)',
...(isWeekend(d) ? { background: '#253555' } : {}),
}}>{fmtDayHeader(d)}</th>
))}
</tr>
<tr>
{dates.map(d => (
<>
<th key={d + '-r'} style={{
fontSize: '0.72rem', fontWeight: 400, minWidth: '72px',
background: isWeekend(d) ? '#2a3d5e' : '#1e2d42',
borderLeft: '2px solid rgba(255,255,255,0.3)',
}}>Rooms</th>
<th key={d + '-d'} style={{
fontSize: '0.72rem', fontWeight: 400, minWidth: '56px', textAlign: 'center',
background: isWeekend(d) ? '#2a3d5e' : '#1e2d42',
borderLeft: '1px solid rgba(255,255,255,0.1)',
}}>D / S / A</th>
</>
))}
</tr>
</thead>
<tbody>
{cats.map((cat, ci) => {
const isLast = ci === cats.length - 1
return (
<CatRows key={cat.id} cat={cat} dates={dates} pickup={pickup}
onPickupChange={(date, delta) => onPickupChange(cat.id, date, delta, cat)}
isLast={isLast}
/>
)
})}
</tbody>
<tfoot>
<TotalRows dates={dates} cats={cats} pickup={pickup} />
</tfoot>
</table>
)
}
function CatRows({ cat, dates, pickup, onPickupChange, isLast }: {
cat: { id: string; name: string; total_rooms: number; days: Record<string, DayData> }
dates: string[]
pickup: PickupData
onPickupChange: (date: string, delta: number) => void
isLast: boolean
}) {
const bbStyle = isLast ? '2px solid var(--card-border)' : undefined
return (
<tr>
<td className="col-label" style={{ borderBottom: bbStyle }}>{cat.name}</td>
{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 */}
<td key={date + '-r'} style={{
textAlign: 'center', verticalAlign: 'middle', borderBottom: bbStyle,
padding: '0.4rem 0.3rem',
borderLeft: '2px solid var(--card-border)',
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
}}>
{(dNew > 0 || dCanc > 0) && (
<div style={{ marginBottom: '2px', fontSize: '0.72rem' }}>
{dNew > 0 && <span className="delta-new">{dNew}</span>}
{dCanc > 0 && <span className="delta-canc"> {dCanc}</span>}
</div>
)}
<div style={{ fontWeight: 700, fontSize: '1.1rem' }} title={occTooltip}>
{occupied}{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', marginBottom: '4px' }}>
{vacant} vac{pu > 0 && <span className="pickup-tag"> ({vacant - pu})</span>}
</div>
<div className="pickup-ctrl">
<button className="pickup-btn" disabled={pu <= 0} onClick={() => onPickupChange(date, -1)}></button>
<span className="pickup-num">{pu}</span>
<button className="pickup-btn" disabled={pu >= vacant}
title={priorLine}
onClick={() => onPickupChange(date, 1)}>+</button>
</div>
</td>
{/* Right cell: D / S / A */}
<td key={date + '-dsa'} style={{
verticalAlign: 'middle', borderLeft: '1px solid var(--card-border)',
borderBottom: bbStyle, padding: '0.4rem 0.3rem', textAlign: 'center',
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
}}>
<div style={{ fontSize: '0.8rem', color: '#c2502e', fontWeight: 600 }}>
{d.departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}
</div>
<div style={{ fontSize: '0.8rem', color: '#1d6fb8', fontWeight: 600 }}>
{d.stays}s
</div>
<div style={{ fontSize: '0.8rem', color: '#1a7a4a', fontWeight: 600 }}>
{d.arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
</td>
</>
)
})}
</tr>
)
}
function TotalRows({ dates, cats, pickup }: {
dates: string[]
cats: BookingsData['categories']
pickup: PickupData
}) {
return (
<tr>
<td className="col-label" style={{ fontStyle: 'italic' }}>Total</td>
{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 (
<>
<td key={date + '-r'} style={{
textAlign: 'center', verticalAlign: 'middle', padding: '0.4rem 0.3rem',
borderLeft: '2px solid var(--card-border)',
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
}}>
{(dNew > 0 || dCanc > 0) && (
<div style={{ marginBottom: '2px', fontSize: '0.72rem' }}>
{dNew > 0 && <span className="delta-new">{dNew}</span>}
{dCanc > 0 && <span className="delta-canc"> {dCanc}</span>}
</div>
)}
<div style={{ fontWeight: 700, fontSize: '1.1rem' }}>
{occupied}{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>
{vacant} vac{pu > 0 && <span className="pickup-tag"> ({vacant - pu})</span>}
</div>
</td>
<td key={date + '-dsa'} style={{
verticalAlign: 'middle', borderLeft: '1px solid var(--card-border)',
padding: '0.4rem 0.3rem', textAlign: 'center',
...(wknd ? { background: 'rgba(100,120,160,0.07)' } : {}),
}}>
<div style={{ fontSize: '0.8rem', color: '#c2502e', fontWeight: 600 }}>
{departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}
</div>
<div style={{ fontSize: '0.8rem', color: '#1d6fb8', fontWeight: 600 }}>{stays}s</div>
<div style={{ fontSize: '0.8rem', color: '#1a7a4a', fontWeight: 600 }}>
{arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
</div>
</td>
</>
)
})}
</tr>
)
}
// ── Required Hours Table ──────────────────────────────────────────────────────
function RequiredTable({ bookings, required }: { bookings: BookingsData; required: Record<string, RequiredDay> }) {
const { dates, categories: cats } = bookings
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Category</th>
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
</tr>
</thead>
<tbody>
{cats.map(cat => (
<tr key={cat.id}>
<td className="col-label">{cat.name}</td>
{dates.map(date => {
const booked = required[date].by_cat[cat.id] || 0
const pu = required[date].pickup_by_cat[cat.id] || 0
return (
<td key={date}>
{booked === 0 && pu === 0 ? '—' : fmtH(booked)}
{pu > 0.001 && <span className="pickup-tag"> (+{fmtH(pu)})</span>}
</td>
)
})}
</tr>
))}
</tbody>
<tfoot>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Booked hrs</td>
{dates.map(d => <td key={d}>{fmtH(required[d].booked)}</td>)}
</tr>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Pickup hrs</td>
{dates.map(d => <td key={d}>{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}</td>)}
</tr>
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>General tasks</td>
{dates.map(d => <td key={d}>{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}</td>)}
</tr>
<tr style={{ background: '#e2e8f0' }}>
<td className="col-label">Total Required</td>
{dates.map(d => (
<td key={d} style={{ fontWeight: 700 }}>
{fmtH(required[d].total)}
{required[d].pickup > 0.001 && (
<span className="pickup-tag" style={{ display: 'block', fontSize: '0.7rem' }}>
inc {fmtH(required[d].pickup)} pickup
</span>
)}
</td>
))}
</tr>
</tfoot>
</table>
)
}
// ── Staff Table ───────────────────────────────────────────────────────────────
function StaffTable({ bookings, staff, required, tolerance, onChange }: {
bookings: BookingsData
staff: StaffMember[]
required: Record<string, RequiredDay>
tolerance: number
onChange: (next: StaffMember[]) => void
}) {
const { dates } = bookings
const tolHrs = tolerance / 60
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))
}
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Staff Member</th>
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
<th style={{ width: '32px' }}></th>
</tr>
</thead>
<tbody>
{staff.map((member, i) => (
<tr key={i}>
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
<input
className="hk-text-input"
value={member.name}
placeholder="Staff name"
onChange={e => setMemberName(i, e.target.value)}
/>
</td>
{dates.map(date => (
<td key={date} style={{ padding: '0.3rem 0.4rem' }}>
<input
type="number" className="hk-num-input"
min={0} max={24} step={0.5}
value={member.hours[date] ?? ''}
placeholder="0"
onChange={e => setMemberHours(i, date, e.target.value)}
/>
</td>
))}
<td>
<button onClick={() => removeRow(i)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)',
fontSize: '1rem', padding: '0.2rem 0.4rem',
}}>×</button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}>{fmtH(avail)}</td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
const reqHrs = required[date].booked + required[date].general
return <td key={date}><DiffCell available={avail} required={reqHrs} tolerance={tolHrs} /></td>
})}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
{dates.map(date => {
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
return <td key={date}><DiffCell available={avail} required={required[date].total} tolerance={tolHrs} /></td>
})}
<td />
</tr>
</tfoot>
</table>
)
}
function DiffCell({ available, required, tolerance }: { available: number; required: number; tolerance: number }) {
const diff = available - required
if (required === 0 && available === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
if (diff < -tolerance) return <span className="diff-under"> {fmtH(Math.abs(diff))} short</span>
if (diff > tolerance) return <span className="diff-over"> {fmtH(diff)} spare</span>
return <span className="diff-ok"> {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
// ── General Tasks Table ───────────────────────────────────────────────────────
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function GeneralTasksTable({ tasks, onChange }: { tasks: GeneralTask[]; onChange: (next: GeneralTask[]) => void }) {
function setName(i: number, name: string) {
onChange(tasks.map((t, idx) => idx === i ? { ...t, name } : t))
}
function setHours(i: number, day: string, val: string) {
const mins = parseInt(val, 10)
onChange(tasks.map((t, idx) => {
if (idx !== i) return t
return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } }
}))
}
function removeRow(i: number) { onChange(tasks.filter((_, idx) => idx !== i)) }
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Task</th>
{WEEKDAYS.map(d => <th key={d}>{d}</th>)}
<th style={{ width: '32px' }}></th>
</tr>
</thead>
<tbody>
{tasks.length === 0 && (
<tr><td colSpan={9} style={{ color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>No tasks yet add one above</td></tr>
)}
{tasks.map((task, i) => (
<tr key={i}>
<td style={{ padding: '0.3rem 0.5rem' }}>
<input className="hk-text-input" value={task.name} placeholder="Task name"
onChange={e => setName(i, e.target.value)} />
</td>
{WEEKDAYS.map(day => (
<td key={day} style={{ padding: '0.3rem 0.4rem' }}>
<input type="number" className="hk-num-input" min={0} max={999}
value={task.hours[day] || ''}
placeholder="0"
onChange={e => setHours(i, day, e.target.value)} />
</td>
))}
<td>
<button onClick={() => removeRow(i)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
}}>×</button>
</td>
</tr>
))}
</tbody>
</table>
)
}
// ── Time Requirements Table ───────────────────────────────────────────────────
function TimeTable({ categories, timeReqs, onChange }: {
categories: BookingsData['categories']
timeReqs: TimeReqs
onChange: (cat: string, action: string, value: number) => void
}) {
return (
<table className="hk-table" style={{ maxWidth: '560px' }}>
<thead>
<tr>
<th className="col-label">Category</th>
<th>Depart (mins)</th>
<th>Stay (mins)</th>
<th>Arrive (mins)</th>
</tr>
</thead>
<tbody>
{categories.map(cat => {
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
return (
<tr key={cat.id}>
<td className="col-label">{cat.name}</td>
{(['depart', 'stay', 'arrive'] as const).map(action => (
<td key={action} style={{ padding: '0.3rem 0.4rem' }}>
<input type="number" className="hk-num-input" min={0} max={999}
value={req[action] || ''}
placeholder="0"
onChange={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
onBlur={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
)
}