Move general tasks + time reqs to Settings; add date adjustments

- Rename 'Category Settings' to 'Settings' (nav, page title, component)
- Move Recurring General Tasks and Time Requirements editing to Settings page
- Planner still loads both for calcRequired calculations
- Add date-specific Adjustments section to Planner: per-date +/- hour
  offsets keyed by YYYY-MM-DD (non-recurring); feed into Required Hours total
- Adjustments row shown in Required Hours tfoot when non-zero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 21:11:21 +00:00
parent b803b01ae1
commit c0b1d2a62e
7 changed files with 416 additions and 238 deletions

View file

@ -1,12 +1,12 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import {
getBookings, getConfig, putTimeReq, putStaff, putPickup,
putGeneralTasks, putLastReviewed, syncWorkforceRota, getWorkforceStaff,
getBookings, getConfig, putStaff, putPickup,
putLastReviewed, putAdjustments, syncWorkforceRota, getWorkforceStaff,
} from '../api'
import type {
BookingsData, TimeReqs, StaffMember, GeneralTask,
PickupData, RequiredDay, DayData, WorkforceRota,
PickupData, RequiredDay, DayData, WorkforceRota, Adjustment,
} from '../types'
// ── Date helpers ──────────────────────────────────────────────────────────────
@ -79,14 +79,16 @@ function getDisplayPickup(
// ── Required hours calculation ────────────────────────────────────────────────
function calcRequired(
bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData, generalTasks: GeneralTask[],
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)
result[date] = { booked: 0, pickup: 0, general: genHrs, total: genHrs, by_cat: {}, pickup_by_cat: {} }
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) {
@ -127,6 +129,7 @@ export function Planner() {
const [pickup, setPickup] = useState<PickupData>({})
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
const [tolerance, setTolerance] = useState(30)
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
const [syncing, setSyncing] = useState(false)
@ -173,6 +176,7 @@ export function Planner() {
setPickup(cfg.pickup_data || {})
setGeneralTasks(cfg.general_tasks || [])
setTolerance(cfg.tolerance_minutes ?? 30)
setAdjustments(cfg.adjustments || [])
setWorkforceRota(cfg.workforce_rota || null)
if (cfg.last_reviewed) {
setSavedLastReviewed(cfg.last_reviewed)
@ -204,13 +208,13 @@ export function Planner() {
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 }))
if (adjustments.length) {
navigator.sendBeacon('/hk-planner/api/config/adjustments', JSON.stringify({ adjustments }))
}
}
window.addEventListener('beforeunload', onUnload)
return () => window.removeEventListener('beforeunload', onUnload)
}, [staff, pickup, generalTasks])
}, [staff, pickup, adjustments])
function handleWeekOffset(days: number) {
const today = todayStr()
@ -249,20 +253,19 @@ export function Planner() {
debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true)))
}
// ── Time requirements ───────────────────────────────────────────────────────
// ── Adjustments ─────────────────────────────────────────────────────────────
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))
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[]) {
@ -275,18 +278,6 @@ export function Planner() {
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: {} }])
}
// ── Workforce sync ───────────────────────────────────────────────────────────
async function syncRota() {
@ -316,7 +307,7 @@ export function Planner() {
// ── Required hours (memoised on state changes) ────────────────────────────
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks, adjustments) : null
// ── Render ──────────────────────────────────────────────────────────────────
@ -417,7 +408,18 @@ export function Planner() {
</div>
</Section>
{/* 3 — Staff rota */}
{/* 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={
@ -453,19 +455,6 @@ export function Planner() {
</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>
@ -748,9 +737,22 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
{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>
<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 => (
@ -922,101 +924,96 @@ function DiffCell({ available, required, tolerance }: { available: number; requi
return <span className="diff-ok"> {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
}
// ── General Tasks Table ───────────────────────────────────────────────────────
// ── Adjustments Table ─────────────────────────────────────────────────────────
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function AdjustmentsTable({ bookings, adjustments, onChange }: {
bookings: BookingsData
adjustments: Adjustment[]
onChange: (next: Adjustment[]) => void
}) {
const { dates } = bookings
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 setLabel(i: number, label: string) {
onChange(adjustments.map((a, idx) => idx === i ? { ...a, label } : a))
}
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 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(tasks.filter((_, idx) => idx !== i)) }
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">Task</th>
{WEEKDAYS.map(d => <th key={d}>{d}</th>)}
<th className="col-label">Label</th>
{dates.map(d => <th key={d}>{fmtDayHeader(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) => (
{adjustments.map((adj, 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 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>
{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] || ''}
{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, day, e.target.value)} />
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',
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>
<tfoot>
<tr>
<th className="col-label">Category</th>
<th>Depart (mins)</th>
<th>Stay (mins)</th>
<th>Arrive (mins)</th>
<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>
</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>
</tfoot>
</table>
)
}