Pull timesheets was writing source='timesheet' rows for future dates (no clock-in data yet), giving ? / 0.00h and preventing rota sync from ever healing those dates. Three-part fix: - Backend runTimesheetSync: cap end date to today before looping - Frontend syncTimesheetData: cap end date to today before calling API - Backend runRotaSync: only skip timesheet-sourced dates that are <= today, so any future dates accidentally marked as timesheet are self-healed next time rota sync runs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1129 lines
47 KiB
TypeScript
1129 lines
47 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 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 || today
|
||
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 today = todayStr()
|
||
const newStart = offsetDate(weekStartRef.current || today, 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 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) { 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) {
|
||
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>
|
||
)
|
||
}
|