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

@ -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) => {

View file

@ -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 }) {
<Routes>
<Route path="/" element={<Navigate to="/planner" replace />} />
<Route path="/planner" element={<Planner />} />
<Route path="/settings" element={can(user, 'settings') ? <CategorySettings /> : <Navigate to="/planner" replace />} />
<Route path="/settings" element={can(user, 'settings') ? <Settings /> : <Navigate to="/planner" replace />} />
<Route path="*" element={<Navigate to="/planner" replace />} />
</Routes>
</Layout>

View file

@ -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<Workforce
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
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 }),
})
}

View file

@ -31,7 +31,7 @@ export function Layout({ user, children }: Props) {
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
<NavItem to="/planner" icon={CalendarClock} label="Planner" />
{can(user, 'settings') && <NavItem to="/settings" icon={Settings} label="Category Settings" />}
{can(user, 'settings') && <NavItem to="/settings" icon={Settings} label="Settings" />}
</div>
<div style={{ padding: '0.75rem 1rem', borderTop: '1px solid var(--surface-2)' }}>

View file

@ -1,9 +1,17 @@
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 WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
export function Settings() {
// ── Category state ─────────────────────────────────────────────────────────
const [cats, setCats] = useState<CategoryConfig[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@ -12,7 +20,7 @@ export function CategorySettings() {
const [msg, setMsg] = useState('')
const [dragIdx, setDragIdx] = useState<number | null>(null)
// Workforce department state
// ── Workforce state ────────────────────────────────────────────────────────
const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([])
const [wfSelected, setWfSelected] = useState<string[]>([])
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<GeneralTask[]>([])
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<TimeReqs>({})
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 <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories</div>
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading</div>
}
return (
<div style={{ padding: '1.5rem', maxWidth: '600px' }}>
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Category Settings
<div style={{ padding: '1.5rem', maxWidth: '680px' }}>
<h1 style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '1.5rem' }}>
Settings
</h1>
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1.5rem' }}>
{/* ── Room Categories ──────────────────────────────────────────────────── */}
<SectionHeading title="Room Categories" />
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Drag to reorder. Toggle to exclude categories from the planner.
</p>
{error && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
{error}
</div>
)}
{msg && (
<div style={{ marginBottom: '1rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
{msg}
</div>
)}
{error && <Banner type="error">{error}</Banner>}
{msg && <Banner type="ok">{msg}</Banner>}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
{cats.map((cat, i) => (
<div
key={cat.id}
@ -167,11 +217,7 @@ export function CategorySettings() {
{cat.room_count} rooms
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>
<input
type="checkbox"
checked={!cat.excluded}
onChange={() => toggleExcluded(i)}
/>
<input type="checkbox" checked={!cat.excluded} onChange={() => toggleExcluded(i)} />
Active
</label>
</div>
@ -179,60 +225,31 @@ export function CategorySettings() {
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
<button
onClick={save}
disabled={saving}
style={{
background: 'var(--hk-green)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
fontWeight: 600,
}}
>
{saving ? 'Saving…' : 'Save Order & Visibility'}
</button>
<button
onClick={handleTest}
disabled={testing}
style={{
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
}}
>
{testing ? 'Testing…' : 'Test Newbook Connection'}
</button>
<Btn onClick={saveCats} disabled={saving} primary>{saving ? 'Saving…' : 'Save Order & Visibility'}</Btn>
<Btn onClick={handleTest} disabled={testing}>{testing ? 'Testing…' : 'Test Newbook Connection'}</Btn>
</div>
{/* ── Workforce Departments ─────────────────────────────────────────────── */}
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Workforce Departments
</h2>
{/* ── Workforce Departments ────────────────────────────────────────────── */}
<Divider />
<SectionHeading title="Workforce Departments" />
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Select the department(s) whose shifts should appear in the HK staff rota.
</p>
{wfNotConfigured && (
<div style={{ padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem' }}>
Workforce integration not configured add the bearer token in&nbsp;<strong>Settings Integrations Workforce</strong>.
<div style={{ padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }}>
Workforce integration not configured add the bearer token in <strong>Settings Integrations Workforce</strong>.
</div>
)}
{!wfNotConfigured && wfLoading && (
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>Loading departments</div>
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '2rem' }}>Loading departments</div>
)}
{!wfNotConfigured && !wfLoading && (
<>
{wfError && (
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
{wfError}
</div>
)}
{wfMsg && (
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
{wfMsg}
</div>
)}
<div style={{ marginBottom: '2.5rem' }}>
{wfError && <Banner type="error">{wfError}</Banner>}
{wfMsg && <Banner type="ok">{wfMsg}</Banner>}
{wfDepts.length === 0 ? (
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '1rem' }}>
@ -251,11 +268,7 @@ export function CategorySettings() {
cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)',
}}
>
<input
type="checkbox"
checked={wfSelected.includes(dept.id)}
onChange={() => toggleWfDept(dept.id)}
/>
<input type="checkbox" checked={wfSelected.includes(dept.id)} onChange={() => toggleWfDept(dept.id)} />
{dept.name}
</label>
))}
@ -263,24 +276,154 @@ export function CategorySettings() {
)}
{wfSelected.length === 0 && wfDepts.length > 0 && (
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--warning, #b45309)' }}>
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: '#b45309' }}>
Select at least one department to enable Workforce sync.
</div>
)}
<button
onClick={saveWfDepts}
disabled={wfSaving}
style={{
background: 'var(--hk-green)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
fontWeight: 600,
}}
>
{wfSaving ? 'Saving…' : 'Save Departments'}
</button>
</>
<Btn onClick={saveWfDepts} disabled={wfSaving} primary>{wfSaving ? 'Saving…' : 'Save Departments'}</Btn>
</div>
)}
{/* ── Recurring General Tasks ──────────────────────────────────────────── */}
<Divider />
<SectionHeading title="Recurring General Tasks" />
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Tasks that recur every week. Enter minutes per day. These add to the total required hours every week.
</p>
{taskError && <Banner type="error">{taskError}</Banner>}
{taskMsg && <Banner type="ok">{taskMsg}</Banner>}
<div style={{ overflowX: 'auto', marginBottom: '1rem' }}>
<table className="hk-table" style={{ minWidth: '560px' }}>
<thead>
<tr>
<th className="col-label">Task</th>
{WEEKDAYS.map(d => <th key={d}>{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</td></tr>
)}
{tasks.map((task, i) => (
<tr key={i}>
<td style={{ padding: '0.3rem 0.5rem' }}>
<input className="hk-text-input" value={task.name} placeholder="Task name"
onChange={e => setTaskName(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] || ''}
placeholder="0"
onChange={e => setTaskHours(i, day, e.target.value)} />
</td>
))}
<td>
<button onClick={() => removeTask(i)} style={{
background: 'none', border: 'none', color: 'var(--text-mid)', fontSize: '1rem', padding: '0.2rem 0.4rem',
}}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
<Btn onClick={() => setTasks([...tasks, { name: '', hours: {} }])}>+ Add task</Btn>
<Btn onClick={saveTasks} disabled={taskSaving} primary>{taskSaving ? 'Saving…' : 'Save Tasks'}</Btn>
</div>
{/* ── Time Requirements ────────────────────────────────────────────────── */}
<Divider />
<SectionHeading title="Time Requirements (minutes per room)" />
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
How many minutes each room type takes depending on guest status.
</p>
{timeError && <Banner type="error">{timeError}</Banner>}
{timeMsg && <Banner type="ok">{timeMsg}</Banner>}
<div style={{ overflowX: 'auto', marginBottom: '2rem' }}>
<table className="hk-table" style={{ maxWidth: '500px' }}>
<thead>
<tr>
<th className="col-label">Category</th>
<th>Depart (mins)</th>
<th>Stay (mins)</th>
<th>Arrive (mins)</th>
</tr>
</thead>
<tbody>
{cats.filter(c => !c.excluded).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 => handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
onBlur={e => handleTimeReqChange(cat.id, action, parseInt(e.target.value, 10) || 0)}
/>
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// ── Small reusable helpers ────────────────────────────────────────────────────
function SectionHeading({ title }: { title: string }) {
return (
<h2 style={{ fontSize: '0.95rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
{title}
</h2>
)
}
function Divider() {
return <div style={{ height: '1px', background: 'var(--card-border)', margin: '0.5rem 0 2rem' }} />
}
function Banner({ type, children }: { type: 'ok' | 'error'; children: React.ReactNode }) {
return (
<div style={{
marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', fontSize: '0.875rem',
background: type === 'ok' ? '#dcfce7' : '#fee2e2',
color: type === 'ok' ? 'var(--success)' : 'var(--danger)',
}}>
{children}
</div>
)
}
function Btn({ children, onClick, disabled, primary }: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
primary?: boolean
}) {
return (
<button onClick={onClick} disabled={disabled} 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.5rem 1.25rem', fontSize: '0.875rem', fontWeight: 600,
}}>
{children}
</button>
)
}

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>
</tr>
</thead>
<tbody>
{categories.map(cat => {
const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 }
<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 (
<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 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>
))}
</tr>
)
})}
</tbody>
<td />
</tr>
</tfoot>
</table>
)
}

View file

@ -49,6 +49,11 @@ export interface GeneralTask {
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
export interface Adjustment {
label: string
hours: Record<string, number> // 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<string, number>
pickup_by_cat: Record<string, number>