hk-planner/frontend/src/pages/Planner.tsx
jtricerolph cbc9db67bc Default week view to Monday of current week
Adds thisMonday() helper and uses it as the fallback wherever the
week start was previously today. The Today button also snaps back
to this week's Monday rather than the literal current date.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-23 12:34:31 +00:00

1136 lines
48 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, 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<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)
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<BookingsData | null>(null)
const [timeReqs, setTimeReqs] = useState<TimeReqs>({})
const [staff, setStaff] = useState<StaffMember[]>([])
const [pickup, setPickup] = useState<PickupData>({})
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
const [warnOverRed, setWarnOverRed] = useState(4)
const [warnOverAmber, setWarnOverAmber] = useState(1)
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
const [warnUnderRed, setWarnUnderRed] = useState(2)
const [wfShifts, setWfShifts] = useState<Record<string, WfDayData>>({})
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
const [syncing, setSyncing] = useState(false)
const [timesheetSyncing, setTimesheetSyncing] = useState(false)
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)
const wfStaffLoaded = useRef(false)
const weekStartRef = useRef<string | null>(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<string, WfDayData>)),
])
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<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)))
}
// ── 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 (
<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) { 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)' }}
/>
<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) { const v = e.target.value; setLastViewed(v); debounce('lastViewed', () => loadAll(true, v), 600) } }}
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 — Adjustments */}
<Section title="Adjustments" action={<CtrlBtn onClick={addAdjustmentRow}>+ Add adjustment</CtrlBtn>}>
<div className="table-scroll">
<AdjustmentsTable
bookings={bookings}
adjustments={adjustments}
onChange={handleAdjustmentsChange}
/>
</div>
</Section>
{/* 4 — Staff rota */}
<Section
title="Staff Rota"
action={
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
onClick={syncRota}
disabled={syncing || timesheetSyncing}
style={{
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}}
>
<RefreshCw size={11} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync rota'}
</button>
<button
onClick={syncTimesheetData}
disabled={syncing || timesheetSyncing}
style={{
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}}
>
<Clock size={11} strokeWidth={1.75} style={{ animation: timesheetSyncing ? 'spin 1s linear infinite' : 'none' }} />
{timesheetSyncing ? 'Pulling…' : 'Pull timesheets'}
</button>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>{wfSyncLabel()}</span>
<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>
</div>
}
>
<div className="table-scroll">
<StaffTable
bookings={bookings}
staff={staff}
required={required}
warnOverRed={warnOverRed}
warnOverAmber={warnOverAmber}
warnUnderAmber={warnUnderAmber}
warnUnderRed={warnUnderRed}
onChange={handleStaffChange}
wfShifts={wfShifts}
wfStaff={wfStaff}
/>
</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' }}>Recurring tasks</td>
{dates.map(d => <td key={d}>{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}</td>)}
</tr>
{dates.some(d => required[d].adjustments !== 0) && (
<tr style={{ background: '#f1f5f9' }}>
<td className="col-label" style={{ color: 'var(--text-mid)', fontSize: '0.78rem' }}>Adjustments</td>
{dates.map(d => {
const adj = required[d].adjustments
return (
<td key={d} style={{ color: adj < 0 ? 'var(--danger)' : adj > 0 ? '#1a7a4a' : 'var(--text-mid)' }}>
{adj === 0 ? '—' : (adj > 0 ? '+' : '') + fmtH(adj)}
</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 pivotWfShifts(wfShifts: Record<string, WfDayData>, dates: string[]): WfStaffMember[] {
const members: Record<string, WfStaffMember> = {}
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<string, RequiredDay>
warnOverRed: number
warnOverAmber: number
warnUnderAmber: number
warnUnderRed: number
onChange: (next: StaffMember[]) => void
wfShifts: Record<string, WfDayData>
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 (
<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>
{/* Workforce rota rows — read-only */}
{rotaMembers.map(member => (
<tr key={'wf-' + member.id} style={{ background: 'rgba(42,100,72,0.05)' }}>
<td className="col-label">
<span style={{
display: 'inline-block', fontSize: '0.67rem', fontWeight: 700,
background: 'rgba(42,100,72,0.18)', color: '#1a7a4a',
borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5',
}}>WF</span>
{member.name}
</td>
{dates.map(date => {
const shift = member.days[date]
const isTimesheet = wfShifts[date]?.source === 'timesheet'
const isPending = isTimesheet && shift?.status !== 'APPROVED'
return (
<td key={date} style={{ textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }}>
{shift ? (
<>
<div style={{ fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }}>{shift.times}</div>
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.2rem' }}>
{isTimesheet && (
<Clock size={10} strokeWidth={1.75} style={{ color: isPending ? '#d97706' : '#1a7a4a', flexShrink: 0 }} />
)}
{fmtH(shift.hours)}
</div>
</>
) : <span style={{ color: 'var(--text-mid)' }}></span>}
</td>
)
})}
<td />
</tr>
))}
{/* Manual rows */}
{wfStaff.length > 0 && (
<datalist id="wf-staff-datalist">
{wfStaff.map(s => <option key={s.id} value={s.name} />)}
</datalist>
)}
{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"
list={wfStaff.length > 0 ? 'wf-staff-datalist' : undefined}
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>
{(() => {
const avail: Record<string, number> = {}
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 (
<>
<tr>
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
{dates.map(date => <td key={date}>{fmtH(avail[date])}</td>)}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} /></td>
))}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Recurring Tasks</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} /></td>
))}
<td />
</tr>
<tr>
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Pickup</td>
{dates.map(date => (
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general + required[date].pickup} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} /></td>
))}
<td />
</tr>
<tr style={{ background: '#e2e8f0' }}>
<td className="col-label" style={{ fontWeight: 700 }}>with Adjustments</td>
{dates.map(date => (
<td key={date}><DiffCell available={avail[date]} required={required[date].total} warnOverRed={warnOverRed} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} warnUnderRed={warnUnderRed} /></td>
))}
<td />
</tr>
</>
)
})()}
</tfoot>
</table>
)
}
function PlainDiffCell({ available, required, warnOverAmber, warnUnderAmber }: {
available: number; required: number; warnOverAmber: number; warnUnderAmber: number
}) {
if (available === 0 && required === 0) return <span style={{ color: 'var(--text-mid)' }}></span>
const diff = available - required
const icon = diff > warnOverAmber ? '⚠' : diff < -warnUnderAmber ? '✗' : '✓'
return <span style={{ color: 'var(--text-mid)', fontSize: '0.82rem' }}>{icon} {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
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 <span style={{ color: 'var(--text-mid)' }}></span>
const diff = available - required
if (diff > warnOverRed) return <span className="diff-over-red"> {fmtH(diff)} spare</span>
if (diff > warnOverAmber) return <span className="diff-over"> {fmtH(diff)} spare</span>
if (diff < -warnUnderRed) return <span className="diff-under"> {fmtH(Math.abs(diff))} short</span>
if (diff < -warnUnderAmber) return <span className="diff-under-amber"> {fmtH(Math.abs(diff))} short</span>
return <span className="diff-ok"> {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
// ── 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 (
<p style={{ color: 'var(--text-mid)', fontSize: '0.82rem', padding: '0.5rem 0' }}>
No adjustments use "+ Add adjustment" above to add a one-off hour offset for a specific date.
</p>
)
}
return (
<table className="hk-table">
<thead>
<tr>
<th className="col-label">Label</th>
{dates.map(d => <th key={d}>{fmtDayHeader(d)}</th>)}
<th style={{ width: '32px' }}></th>
</tr>
</thead>
<tbody>
{adjustments.map((adj, i) => (
<tr key={i}>
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
<input
className="hk-text-input"
value={adj.label}
placeholder="e.g. Rooms from Sunday"
onChange={e => setLabel(i, e.target.value)}
/>
</td>
{dates.map(date => (
<td key={date} style={{ padding: '0.3rem 0.4rem' }}>
<input
type="number" className="hk-num-input"
step={0.25}
value={adj.hours[date] ?? ''}
placeholder="0"
onChange={e => setHours(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</td>
{dates.map(date => {
const total = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0)
return (
<td key={date} style={{ color: total < 0 ? 'var(--danger)' : total > 0 ? '#1a7a4a' : 'var(--text-mid)', fontWeight: total !== 0 ? 600 : undefined }}>
{total === 0 ? '—' : (total > 0 ? '+' : '') + fmtH(total)}
</td>
)
})}
<td />
</tr>
</tfoot>
</table>
)
}