Initial commit: HK Planner app
Housekeeping workload and hours planning app — port of the WP hotel housekeeping hours calculator plugin. 7-day occupancy planner with Newbook PMS integration, staff rota, time requirements, and pickup tracking. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
4abae5eda1
28 changed files with 2475 additions and 0 deletions
857
frontend/src/pages/Planner.tsx
Normal file
857
frontend/src/pages/Planner.tsx
Normal file
|
|
@ -0,0 +1,857 @@
|
|||
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 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(1) + '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}>{fmtDayHeader(d)}</th>)}
|
||||
</tr>
|
||||
<tr>
|
||||
{dates.map(d => (
|
||||
<th key={d} colSpan={2} style={{ fontSize: '0.72rem', fontWeight: 400, background: '#1e2d42' }}>
|
||||
<span style={{ padding: '0 0.5rem' }}>Occ</span>
|
||||
<span style={{ borderLeft: '1px solid rgba(255,255,255,0.15)', padding: '0 0.5rem' }}>D / S / A</span>
|
||||
</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
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Row 1: Occ + Departs */}
|
||||
<tr className="row-depart">
|
||||
<td className="col-label" rowSpan={3} style={{ borderBottom: isLast ? '2px solid var(--card-border)' : undefined }}>
|
||||
{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
|
||||
|
||||
return (
|
||||
<td key={date} colSpan={2} rowSpan={3}
|
||||
style={{ verticalAlign: 'top', paddingTop: '0.5rem',
|
||||
borderBottom: isLast ? '2px solid var(--card-border)' : undefined }}>
|
||||
{(dNew > 0 || dCanc > 0) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.25rem', marginBottom: '2px' }}>
|
||||
{dNew > 0 && <span className="delta-new">▲{dNew}</span>}
|
||||
{dCanc > 0 && <span className="delta-canc">▼{dCanc}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ textAlign: 'center', fontWeight: 700, fontSize: '1rem' }}>
|
||||
{occupied}
|
||||
{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', fontSize: '0.78rem', color: 'var(--text-mid)' }}>
|
||||
{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={d.pickup_hint > 0 ? `${d.pickup_hint} late pickup${d.pickup_hint > 1 ? 's' : ''} last ${getDayName(date)}` : 'No late pickups last week'}
|
||||
onClick={() => onPickupChange(date, 1)}>+</button>
|
||||
</div>
|
||||
{/* D/S/A breakdown: sits to the right via colSpan trick — shown as sub-rows via CSS */}
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '4px', borderTop: '1px dashed var(--card-border)', paddingTop: '3px' }}>
|
||||
<span style={{ color: '#c2502e' }}>{d.departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}</span>
|
||||
{' / '}
|
||||
<span style={{ color: '#1d6fb8' }}>{d.stays}s</span>
|
||||
{' / '}
|
||||
<span style={{ color: '#1a7a4a' }}>{d.arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}</span>
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
{/* Rows 2 & 3 are consumed by rowSpan — needed for tbody structure */}
|
||||
<tr className="row-stay" />
|
||||
<tr className={`row-arrive${isLast ? ' row-group-last' : ''}`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return (
|
||||
<td key={date} colSpan={2}>
|
||||
{(dNew > 0 || dCanc > 0) && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '0.25rem', marginBottom: '2px' }}>
|
||||
{dNew > 0 && <span className="delta-new">▲{dNew}</span>}
|
||||
{dCanc > 0 && <span className="delta-canc">▼{dCanc}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontWeight: 700 }}>
|
||||
{occupied}{pu > 0 && <span className="pickup-tag"> +{pu}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>
|
||||
{vacant} vac{pu > 0 && <span className="pickup-tag"> ({vacant - pu})</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-mid)', borderTop: '1px dashed var(--card-border)', paddingTop: '3px', marginTop: '3px' }}>
|
||||
<span style={{ color: '#c2502e' }}>{departs}d{prevPu > 0 && <span className="pickup-tag"> +{prevPu}</span>}</span>
|
||||
{' / '}
|
||||
<span style={{ color: '#1d6fb8' }}>{stays}s</span>
|
||||
{' / '}
|
||||
<span style={{ color: '#1a7a4a' }}>{arrivals}a{pu > 0 && <span className="pickup-tag"> +{pu}</span>}</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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue