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>
1136 lines
48 KiB
TypeScript
1136 lines
48 KiB
TypeScript
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>
|
||
)
|
||
}
|