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:
parent
b803b01ae1
commit
c0b1d2a62e
7 changed files with 416 additions and 238 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue