From c0b1d2a62ef2a383167428aaa93b9734286a3447 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 7 Jul 2026 21:11:21 +0000 Subject: [PATCH] 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 --- backend/src/routes/config.js | 25 +- frontend/src/App.tsx | 4 +- frontend/src/api.ts | 11 +- frontend/src/components/Layout.tsx | 2 +- frontend/src/pages/CategorySettings.tsx | 387 ++++++++++++++++-------- frontend/src/pages/Planner.tsx | 219 +++++++------- frontend/src/types.ts | 6 + 7 files changed, 416 insertions(+), 238 deletions(-) diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js index 939189c..461b348 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -8,7 +8,7 @@ export async function configRoutes(app) { // ── GET /api/config — all settings at once ────────────────────────────── app.get('/api/config', async (req) => { - const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts] = + const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts, adjustments] = await Promise.all([ getConfig('time_requirements', {}), getConfig('staff_data', []), @@ -18,6 +18,7 @@ export async function configRoutes(app) { getConfig('tolerance_minutes', 30), getConfig('workforce_rota', null), getConfig('workforce_departments', []), + getConfig('adjustments', []), ]) const today = new Date() @@ -35,6 +36,7 @@ export async function configRoutes(app) { tolerance_minutes: toleranceMins != null ? toleranceMins : 30, workforce_rota: workforceRota || null, workforce_departments: workforceDepts || [], + adjustments: adjustments || [], } }) @@ -138,6 +140,27 @@ export async function configRoutes(app) { return { ok: true } }) + // ── PUT /api/config/adjustments ────────────────────────────────────────── + + app.put('/api/config/adjustments', async (req, reply) => { + const { adjustments } = req.body || {} + if (!Array.isArray(adjustments)) return reply.status(400).send({ error: 'Invalid data' }) + + const clean = adjustments + .filter(a => a && typeof a.label === 'string') + .map(a => ({ + label: a.label.trim(), + hours: Object.fromEntries( + Object.entries(a.hours || {}) + .filter(([k]) => /^\d{4}-\d{2}-\d{2}$/.test(k)) + .map(([k, v]) => [k, parseFloat(v) || 0]) + ), + })) + + await setConfig('adjustments', clean) + return { ok: true } + }) + // ── GET /api/categories — settings cap required ────────────────────────── app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5ec171..471bd86 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthGate } from './components/AuthGate' import { Layout } from './components/Layout' import { Planner } from './pages/Planner' -import { CategorySettings } from './pages/CategorySettings' +import { Settings } from './pages/CategorySettings' import { can } from './types' import type { User } from './types' @@ -12,7 +12,7 @@ function AppRoutes({ user }: { user: User }) { } /> } /> - : } /> + : } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 15c764d..95dabb4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota } from './types' +import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota, Adjustment } from './types' const BASE = '/hk-planner/api' @@ -11,6 +11,7 @@ export interface ConfigData { tolerance_minutes: number workforce_rota: WorkforceRota | null workforce_departments: string[] + adjustments: Adjustment[] } export interface CategoryConfig { @@ -116,3 +117,11 @@ export function syncWorkforceRota(start: string, end: string): Promise { return request('/workforce/staff') } + +export function putAdjustments(adjustments: Adjustment[]): Promise<{ ok: boolean }> { + return request('/config/adjustments', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adjustments }), + }) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index beffb1a..61aaac7 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -31,7 +31,7 @@ export function Layout({ user, children }: Props) {
- {can(user, 'settings') && } + {can(user, 'settings') && }
diff --git a/frontend/src/pages/CategorySettings.tsx b/frontend/src/pages/CategorySettings.tsx index 37f9f77..2e7c438 100644 --- a/frontend/src/pages/CategorySettings.tsx +++ b/frontend/src/pages/CategorySettings.tsx @@ -1,18 +1,26 @@ import { useState, useEffect } from 'react' import { GripVertical } from 'lucide-react' -import { getCategories, putCategories, testNewbook, getConfig, getWorkforceDepartments, putWorkforceDepartments } from '../api' +import { + getCategories, putCategories, testNewbook, + getConfig, getWorkforceDepartments, putWorkforceDepartments, + putGeneralTasks, putTimeReq, +} from '../api' import type { CategoryConfig } from '../api' +import type { GeneralTask, TimeReqs } from '../types' -export function CategorySettings() { - const [cats, setCats] = useState([]) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) - const [error, setError] = useState('') - const [msg, setMsg] = useState('') - const [dragIdx, setDragIdx] = useState(null) +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] - // Workforce department state +export function Settings() { + // ── Category state ───────────────────────────────────────────────────────── + const [cats, setCats] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [dragIdx, setDragIdx] = useState(null) + + // ── Workforce state ──────────────────────────────────────────────────────── const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([]) const [wfSelected, setWfSelected] = useState([]) const [wfLoading, setWfLoading] = useState(true) @@ -21,14 +29,21 @@ export function CategorySettings() { const [wfSaving, setWfSaving] = useState(false) const [wfMsg, setWfMsg] = useState('') - useEffect(() => { - getCategories() - .then(r => { setCats(r.categories); setLoading(false) }) - .catch(e => { setError(e.message); setLoading(false) }) + // ── Recurring general tasks state ────────────────────────────────────────── + const [tasks, setTasks] = useState([]) + const [taskSaving, setTaskSaving] = useState(false) + const [taskMsg, setTaskMsg] = useState('') + const [taskError, setTaskError] = useState('') - // Load selected dept IDs from config and dept list in parallel + // ── Time requirements state ──────────────────────────────────────────────── + const [timeReqs, setTimeReqs] = useState({}) + const [timeMsg, setTimeMsg] = useState('') + const [timeError, setTimeError] = useState('') + + useEffect(() => { Promise.all([ - getConfig().then(c => c.workforce_departments), + getCategories(), + getConfig(), getWorkforceDepartments().catch((e: Error) => { if (e.message.includes('503') || e.message.toLowerCase().includes('not configured')) { setWfNotConfigured(true) @@ -37,13 +52,23 @@ export function CategorySettings() { } return null }), - ]).then(([selected, depts]) => { - if (selected) setWfSelected(selected) + ]).then(([catResp, cfg, depts]) => { + setCats(catResp.categories) + setTasks(cfg.general_tasks || []) + setTimeReqs(cfg.time_requirements || {}) + setWfSelected(cfg.workforce_departments || []) if (depts) setWfDepts(depts) + setLoading(false) + setWfLoading(false) + }).catch(e => { + setError(e.message) + setLoading(false) setWfLoading(false) }) }, []) + // ── Category handlers ────────────────────────────────────────────────────── + function toggleExcluded(i: number) { setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c)) } @@ -65,81 +90,106 @@ export function CategorySettings() { function onDragEnd() { setDragIdx(null) } - async function save() { - setSaving(true) - setError('') - setMsg('') + async function saveCats() { + setSaving(true); setError(''); setMsg('') try { - const order = cats.map(c => c.id) - const excluded = cats.filter(c => c.excluded).map(c => c.id) - await putCategories(order, excluded) - setMsg('Saved') - setTimeout(() => setMsg(''), 2500) + await putCategories(cats.map(c => c.id), cats.filter(c => c.excluded).map(c => c.id)) + setMsg('Saved'); setTimeout(() => setMsg(''), 2500) } catch (e) { setError(e instanceof Error ? e.message : 'Save failed') - } finally { - setSaving(false) - } + } finally { setSaving(false) } } async function handleTest() { - setTesting(true) - setError('') - setMsg('') + setTesting(true); setError(''); setMsg('') try { const res = await testNewbook() setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`) } catch (e) { setError(e instanceof Error ? e.message : 'Test failed') - } finally { - setTesting(false) - } + } finally { setTesting(false) } } + // ── Workforce handlers ───────────────────────────────────────────────────── + function toggleWfDept(id: string) { setWfSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]) } async function saveWfDepts() { - setWfSaving(true) - setWfError('') - setWfMsg('') + setWfSaving(true); setWfError(''); setWfMsg('') try { await putWorkforceDepartments(wfSelected) - setWfMsg('Departments saved') - setTimeout(() => setWfMsg(''), 2500) + setWfMsg('Departments saved'); setTimeout(() => setWfMsg(''), 2500) } catch (e) { setWfError(e instanceof Error ? e.message : 'Save failed') - } finally { - setWfSaving(false) + } finally { setWfSaving(false) } + } + + // ── Recurring tasks handlers ─────────────────────────────────────────────── + + function setTaskName(i: number, name: string) { + setTasks(tasks.map((t, idx) => idx === i ? { ...t, name } : t)) + } + + function setTaskHours(i: number, day: string, val: string) { + const mins = parseInt(val, 10) + setTasks(tasks.map((t, idx) => { + if (idx !== i) return t + return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } } + })) + } + + function removeTask(i: number) { + setTasks(tasks.filter((_, idx) => idx !== i)) + } + + async function saveTasks() { + setTaskSaving(true); setTaskError(''); setTaskMsg('') + try { + await putGeneralTasks(tasks) + setTaskMsg('Saved'); setTimeout(() => setTaskMsg(''), 2500) + } catch (e) { + setTaskError(e instanceof Error ? e.message : 'Save failed') + } finally { setTaskSaving(false) } + } + + // ── Time requirements handlers ───────────────────────────────────────────── + + async function handleTimeReqChange(catId: string, action: string, value: number) { + const next = { + ...timeReqs, + [catId]: { ...(timeReqs[catId] || { depart: 0, stay: 0, arrive: 0 }), [action]: value }, + } + setTimeReqs(next) + try { + await putTimeReq(catId, action, value) + setTimeMsg('Saved'); setTimeout(() => setTimeMsg(''), 1500) + } catch (e) { + setTimeError(e instanceof Error ? e.message : 'Save failed') } } if (loading) { - return
Loading categories…
+ return
Loading…
} return ( -
-

- Category Settings +
+

+ Settings

-

+ + {/* ── Room Categories ──────────────────────────────────────────────────── */} + +

Drag to reorder. Toggle to exclude categories from the planner.

- {error && ( -
- {error} -
- )} - {msg && ( -
- {msg} -
- )} + {error && {error}} + {msg && {msg}} -
+
{cats.map((cat, i) => (
@@ -179,60 +225,31 @@ export function CategorySettings() {
- - + {saving ? 'Saving…' : 'Save Order & Visibility'} + {testing ? 'Testing…' : 'Test Newbook Connection'}
- {/* ── Workforce Departments ─────────────────────────────────────────────── */} -

- Workforce Departments -

+ {/* ── Workforce Departments ────────────────────────────────────────────── */} + +

Select the department(s) whose shifts should appear in the HK staff rota.

{wfNotConfigured && ( -
- Workforce integration not configured — add the bearer token in Settings → Integrations → Workforce. +
+ Workforce integration not configured — add the bearer token in Settings → Integrations → Workforce.
)} {!wfNotConfigured && wfLoading && ( -
Loading departments…
+
Loading departments…
)} {!wfNotConfigured && !wfLoading && ( - <> - {wfError && ( -
- {wfError} -
- )} - {wfMsg && ( -
- {wfMsg} -
- )} +
+ {wfError && {wfError}} + {wfMsg && {wfMsg}} {wfDepts.length === 0 ? (
@@ -251,11 +268,7 @@ export function CategorySettings() { cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)', }} > - toggleWfDept(dept.id)} - /> + toggleWfDept(dept.id)} /> {dept.name} ))} @@ -263,24 +276,154 @@ export function CategorySettings() { )} {wfSelected.length === 0 && wfDepts.length > 0 && ( -
+
Select at least one department to enable Workforce sync.
)} - - + {wfSaving ? 'Saving…' : 'Save Departments'} +
)} + + {/* ── Recurring General Tasks ──────────────────────────────────────────── */} + + +

+ Tasks that recur every week. Enter minutes per day. These add to the total required hours every week. +

+ + {taskError && {taskError}} + {taskMsg && {taskMsg}} + +
+ + + + + {WEEKDAYS.map(d => )} + + + + + {tasks.length === 0 && ( + + )} + {tasks.map((task, i) => ( + + + {WEEKDAYS.map(day => ( + + ))} + + + ))} + +
Task{d}
No tasks yet
+ setTaskName(i, e.target.value)} /> + + setTaskHours(i, day, e.target.value)} /> + + +
+
+ +
+ setTasks([...tasks, { name: '', hours: {} }])}>+ Add task + {taskSaving ? 'Saving…' : 'Save Tasks'} +
+ + {/* ── Time Requirements ────────────────────────────────────────────────── */} + + +

+ How many minutes each room type takes depending on guest status. +

+ + {timeError && {timeError}} + {timeMsg && {timeMsg}} + +
+ + + + + + + + + + + {cats.filter(c => !c.excluded).map(cat => { + const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 } + return ( + + + {(['depart', 'stay', 'arrive'] as const).map(action => ( + + ))} + + ) + })} + +
CategoryDepart (mins)Stay (mins)Arrive (mins)
{cat.name} + handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0)} + onBlur={e => handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0)} + /> +
+
) } + +// ── Small reusable helpers ──────────────────────────────────────────────────── + +function SectionHeading({ title }: { title: string }) { + return ( +

+ {title} +

+ ) +} + +function Divider() { + return
+} + +function Banner({ type, children }: { type: 'ok' | 'error'; children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function Btn({ children, onClick, disabled, primary }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + primary?: boolean +}) { + return ( + + ) +} diff --git a/frontend/src/pages/Planner.tsx b/frontend/src/pages/Planner.tsx index a581416..bd1f7a7 100644 --- a/frontend/src/pages/Planner.tsx +++ b/frontend/src/pages/Planner.tsx @@ -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 { const result: Record = {} 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({}) const [generalTasks, setGeneralTasks] = useState([]) const [tolerance, setTolerance] = useState(30) + const [adjustments, setAdjustments] = useState([]) const [workforceRota, setWorkforceRota] = useState(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() {
- {/* 3 — Staff rota */} + {/* 3 — Adjustments */} +
+ Add adjustment}> +
+ +
+
+ + {/* 4 — Staff rota */}
- {/* 4 — General tasks */} -
+ Add task}> -
- -
-
- - {/* 5 — Time requirements */} -
-
- -
-
)}
@@ -748,9 +737,22 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require {dates.map(d => {required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'})} - General tasks + Recurring tasks {dates.map(d => {required[d].general > 0.001 ? fmtH(required[d].general) : '—'})} + {dates.some(d => required[d].adjustments !== 0) && ( + + Adjustments + {dates.map(d => { + const adj = required[d].adjustments + return ( + 0 ? '#1a7a4a' : 'var(--text-mid)' }}> + {adj === 0 ? '—' : (adj > 0 ? '+' : '') + fmtH(adj)} + + ) + })} + + )} Total Required {dates.map(d => ( @@ -922,101 +924,96 @@ function DiffCell({ available, required, tolerance }: { available: number; requi return ✓ {diff >= 0 ? '+' : ''}{fmtH(diff)} } -// ── 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 ( +

+ No adjustments — use "+ Add adjustment" above to add a one-off hour offset for a specific date. +

+ ) + } return ( - - {WEEKDAYS.map(d => )} + + {dates.map(d => )} - {tasks.length === 0 && ( - - )} - {tasks.map((task, i) => ( + {adjustments.map((adj, i) => ( - - {WEEKDAYS.map(day => ( - ))} ))} -
Task{d}Label{fmtDayHeader(d)}
No tasks yet — add one above
- setName(i, e.target.value)} /> + + setLabel(i, e.target.value)} + /> - ( + + setHours(i, day, e.target.value)} /> + onChange={e => setHours(i, date, e.target.value)} + />
- ) -} - -// ── Time Requirements Table ─────────────────────────────────────────────────── - -function TimeTable({ categories, timeReqs, onChange }: { - categories: BookingsData['categories'] - timeReqs: TimeReqs - onChange: (cat: string, action: string, value: number) => void -}) { - return ( - - + - - - - + + {dates.map(date => { + const total = adjustments.reduce((s, a) => s + (a.hours[date] || 0), 0) + return ( + + ) + })} + - - - {categories.map(cat => { - const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 } - return ( - - - {(['depart', 'stay', 'arrive'] as const).map(action => ( - - ))} - - ) - })} - +
CategoryDepart (mins)Stay (mins)Arrive (mins)Total 0 ? '#1a7a4a' : 'var(--text-mid)', fontWeight: total !== 0 ? 600 : undefined }}> + {total === 0 ? '—' : (total > 0 ? '+' : '') + fmtH(total)} +
{cat.name} - onChange(cat.id, action, parseInt(e.target.value, 10) || 0)} - onBlur={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)} - /> -
) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b50575c..57b1b68 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,6 +49,11 @@ export interface GeneralTask { export type PickupData = Record> +export interface Adjustment { + label: string + hours: Record // YYYY-MM-DD → positive or negative hours +} + export interface WorkforceShiftDay { hours: number times: string @@ -70,6 +75,7 @@ export interface RequiredDay { booked: number pickup: number general: number + adjustments: number total: number by_cat: Record pickup_by_cat: Record