Add 5-tier warning thresholds to staff rota footer
Replaces the 2-row (vs Booked / vs with Pickup) footer with a progressive 5-row breakdown: - Total Available (unchanged) - vs Booked — plain diff, room cleaning only - with Recurring Tasks — plain diff, adds recurring tasks - with Pickup — plain diff, adds pickup hours - with Adjustments — bold coloured final row with configurable thresholds New 5-tier icon system: red ⚠ / amber ⚠ / green ✓ / amber ✗ / red ✗, with defaults of 4h / 1h / 1h / 2h spare or short. Thresholds are editable in Settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fee388db4c
commit
75ac7591d2
7 changed files with 256 additions and 20 deletions
|
|
@ -8,18 +8,24 @@ export async function configRoutes(app) {
|
||||||
// ── GET /api/config — all settings at once ──────────────────────────────
|
// ── GET /api/config — all settings at once ──────────────────────────────
|
||||||
|
|
||||||
app.get('/api/config', async (req) => {
|
app.get('/api/config', async (req) => {
|
||||||
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts, adjustments] =
|
const [
|
||||||
await Promise.all([
|
timeReqs, staffData, pickupData, generalTasks, lastReviewed,
|
||||||
getConfig('time_requirements', {}),
|
workforceRota, workforceDepts, adjustments,
|
||||||
getConfig('staff_data', []),
|
warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed,
|
||||||
getConfig('pickup_data', {}),
|
] = await Promise.all([
|
||||||
getConfig('general_tasks', []),
|
getConfig('time_requirements', {}),
|
||||||
getConfig('last_reviewed', null),
|
getConfig('staff_data', []),
|
||||||
getConfig('tolerance_minutes', 30),
|
getConfig('pickup_data', {}),
|
||||||
getConfig('workforce_rota', null),
|
getConfig('general_tasks', []),
|
||||||
getConfig('workforce_departments', []),
|
getConfig('last_reviewed', null),
|
||||||
getConfig('adjustments', []),
|
getConfig('workforce_rota', null),
|
||||||
])
|
getConfig('workforce_departments', []),
|
||||||
|
getConfig('adjustments', []),
|
||||||
|
getConfig('warn_over_red_hrs', 4),
|
||||||
|
getConfig('warn_over_amber_hrs', 1),
|
||||||
|
getConfig('warn_under_amber_hrs', 1),
|
||||||
|
getConfig('warn_under_red_hrs', 2),
|
||||||
|
])
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
@ -33,10 +39,13 @@ export async function configRoutes(app) {
|
||||||
pickup_data: pickupData || {},
|
pickup_data: pickupData || {},
|
||||||
general_tasks: generalTasks || [],
|
general_tasks: generalTasks || [],
|
||||||
last_reviewed: lastReviewed || yestStr,
|
last_reviewed: lastReviewed || yestStr,
|
||||||
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
|
||||||
workforce_rota: workforceRota || null,
|
workforce_rota: workforceRota || null,
|
||||||
workforce_departments: workforceDepts || [],
|
workforce_departments: workforceDepts || [],
|
||||||
adjustments: adjustments || [],
|
adjustments: adjustments || [],
|
||||||
|
warn_over_red_hrs: warnOverRed != null ? warnOverRed : 4,
|
||||||
|
warn_over_amber_hrs: warnOverAmber != null ? warnOverAmber : 1,
|
||||||
|
warn_under_amber_hrs: warnUnderAmber != null ? warnUnderAmber : 1,
|
||||||
|
warn_under_red_hrs: warnUnderRed != null ? warnUnderRed : 2,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -161,6 +170,20 @@ export async function configRoutes(app) {
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── PUT /api/config/warning-thresholds ──────────────────────────────────
|
||||||
|
|
||||||
|
app.put('/api/config/warning-thresholds', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
const { warn_over_red_hrs, warn_over_amber_hrs, warn_under_amber_hrs, warn_under_red_hrs } = req.body || {}
|
||||||
|
const parse = (v, def) => { const n = parseFloat(v); return isNaN(n) || n < 0 ? def : n }
|
||||||
|
await Promise.all([
|
||||||
|
setConfig('warn_over_red_hrs', parse(warn_over_red_hrs, 4)),
|
||||||
|
setConfig('warn_over_amber_hrs', parse(warn_over_amber_hrs, 1)),
|
||||||
|
setConfig('warn_under_amber_hrs', parse(warn_under_amber_hrs, 1)),
|
||||||
|
setConfig('warn_under_red_hrs', parse(warn_under_red_hrs, 2)),
|
||||||
|
])
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /api/categories — settings cap required ──────────────────────────
|
// ── GET /api/categories — settings cap required ──────────────────────────
|
||||||
|
|
||||||
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
|
|
||||||
127
frontend/dist/assets/index-DYQJ_2ny.js
vendored
Normal file
127
frontend/dist/assets/index-DYQJ_2ny.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
frontend/dist/index.html
vendored
2
frontend/dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
||||||
<meta name="viewport" content="width=1280" />
|
<meta name="viewport" content="width=1280" />
|
||||||
<meta name="theme-color" content="#2d6a4f" />
|
<meta name="theme-color" content="#2d6a4f" />
|
||||||
<title>HK Planner</title>
|
<title>HK Planner</title>
|
||||||
<script type="module" crossorigin src="/hk-planner/assets/index-CG3D4SXd.js"></script>
|
<script type="module" crossorigin src="/hk-planner/assets/index-DYQJ_2ny.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
|
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-B7_UXJgZ.css">
|
||||||
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
|
<link rel="manifest" href="/hk-planner/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/hk-planner/registerSW.js"></script></head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
2
frontend/dist/sw.js
vendored
2
frontend/dist/sw.js
vendored
|
|
@ -1 +1 @@
|
||||||
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const d=e=>n(e,o),c={module:{uri:o},exports:t,require:d};i[o]=Promise.all(s.map(e=>c[e]||d(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"efb9dd576a766e55ff7952115f902789"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-CG3D4SXd.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});
|
if(!self.define){let e,i={};const n=(n,s)=>(n=new URL(n+".js",s).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(s,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(i[o])return;let t={};const c=e=>n(e,o),d={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>d[e]||c(e))).then(e=>(r(...e),t))}}define(["./workbox-9c191d2f"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"registerSW.js",revision:"a2c395d8c225f1b3ea12388f15189bce"},{url:"index.html",revision:"0913278d5fa0fe282bff5174b4656bbf"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-DYQJ_2ny.js",revision:null},{url:"assets/index-B7_UXJgZ.css",revision:null},{url:"manifest.webmanifest",revision:"c2510de876adb84db0c4b300b71216fa"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/hk-planner/index.html"),{denylist:[/\/api\//]}))});
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ export interface ConfigData {
|
||||||
pickup_data: PickupData
|
pickup_data: PickupData
|
||||||
general_tasks: GeneralTask[]
|
general_tasks: GeneralTask[]
|
||||||
last_reviewed: string
|
last_reviewed: string
|
||||||
tolerance_minutes: number
|
|
||||||
workforce_rota: WorkforceRota | null
|
workforce_rota: WorkforceRota | null
|
||||||
workforce_departments: string[]
|
workforce_departments: string[]
|
||||||
adjustments: Adjustment[]
|
adjustments: Adjustment[]
|
||||||
|
warn_over_red_hrs: number
|
||||||
|
warn_over_amber_hrs: number
|
||||||
|
warn_under_amber_hrs: number
|
||||||
|
warn_under_red_hrs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryConfig {
|
export interface CategoryConfig {
|
||||||
|
|
@ -129,3 +132,16 @@ export function putAdjustments(adjustments: Adjustment[]): Promise<{ ok: boolean
|
||||||
body: JSON.stringify({ adjustments }),
|
body: JSON.stringify({ adjustments }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function putWarningThresholds(thresholds: {
|
||||||
|
warn_over_red_hrs: number
|
||||||
|
warn_over_amber_hrs: number
|
||||||
|
warn_under_amber_hrs: number
|
||||||
|
warn_under_red_hrs: number
|
||||||
|
}): Promise<{ ok: boolean }> {
|
||||||
|
return request('/config/warning-thresholds', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(thresholds),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,9 +111,11 @@ input, textarea, select { font-family: inherit; }
|
||||||
.pickup-num { min-width: 16px; text-align: center; font-size: 0.8rem; font-weight: 600; }
|
.pickup-num { min-width: 16px; text-align: center; font-size: 0.8rem; font-weight: 600; }
|
||||||
|
|
||||||
/* Diff cells */
|
/* Diff cells */
|
||||||
.diff-ok { color: var(--success); font-weight: 600; }
|
.diff-ok { color: var(--success); font-weight: 600; }
|
||||||
.diff-under { color: var(--danger); font-weight: 600; }
|
.diff-under { color: var(--danger); font-weight: 600; }
|
||||||
.diff-over { color: var(--warning); font-weight: 600; }
|
.diff-under-amber { color: var(--warning); font-weight: 600; }
|
||||||
|
.diff-over { color: var(--warning); font-weight: 600; }
|
||||||
|
.diff-over-red { color: var(--danger); font-weight: 600; }
|
||||||
|
|
||||||
/* Numeric inputs in tables */
|
/* Numeric inputs in tables */
|
||||||
.hk-num-input {
|
.hk-num-input {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { GripVertical } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getCategories, putCategories, testNewbook,
|
getCategories, putCategories, testNewbook,
|
||||||
getConfig, getWorkforceDepartments, putWorkforceDepartments,
|
getConfig, getWorkforceDepartments, putWorkforceDepartments,
|
||||||
putGeneralTasks, putTimeReq,
|
putGeneralTasks, putTimeReq, putWarningThresholds,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type { CategoryConfig } from '../api'
|
import type { CategoryConfig } from '../api'
|
||||||
import type { GeneralTask, TimeReqs } from '../types'
|
import type { GeneralTask, TimeReqs } from '../types'
|
||||||
|
|
@ -40,6 +40,15 @@ export function Settings() {
|
||||||
const [timeMsg, setTimeMsg] = useState('')
|
const [timeMsg, setTimeMsg] = useState('')
|
||||||
const [timeError, setTimeError] = useState('')
|
const [timeError, setTimeError] = useState('')
|
||||||
|
|
||||||
|
// ── Warning threshold state ────────────────────────────────────────────────
|
||||||
|
const [warnOverRed, setWarnOverRed] = useState(4)
|
||||||
|
const [warnOverAmber, setWarnOverAmber] = useState(1)
|
||||||
|
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
|
||||||
|
const [warnUnderRed, setWarnUnderRed] = useState(2)
|
||||||
|
const [warnSaving, setWarnSaving] = useState(false)
|
||||||
|
const [warnMsg, setWarnMsg] = useState('')
|
||||||
|
const [warnError, setWarnError] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getCategories(),
|
getCategories(),
|
||||||
|
|
@ -57,6 +66,10 @@ export function Settings() {
|
||||||
setTasks(cfg.general_tasks || [])
|
setTasks(cfg.general_tasks || [])
|
||||||
setTimeReqs(cfg.time_requirements || {})
|
setTimeReqs(cfg.time_requirements || {})
|
||||||
setWfSelected(cfg.workforce_departments || [])
|
setWfSelected(cfg.workforce_departments || [])
|
||||||
|
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)
|
||||||
if (depts) setWfDepts(depts)
|
if (depts) setWfDepts(depts)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setWfLoading(false)
|
setWfLoading(false)
|
||||||
|
|
@ -170,6 +183,23 @@ export function Settings() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Warning threshold handlers ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function saveWarnThresholds() {
|
||||||
|
setWarnSaving(true); setWarnError(''); setWarnMsg('')
|
||||||
|
try {
|
||||||
|
await putWarningThresholds({
|
||||||
|
warn_over_red_hrs: warnOverRed,
|
||||||
|
warn_over_amber_hrs: warnOverAmber,
|
||||||
|
warn_under_amber_hrs: warnUnderAmber,
|
||||||
|
warn_under_red_hrs: warnUnderRed,
|
||||||
|
})
|
||||||
|
setWarnMsg('Saved'); setTimeout(() => setWarnMsg(''), 2500)
|
||||||
|
} catch (e) {
|
||||||
|
setWarnError(e instanceof Error ? e.message : 'Save failed')
|
||||||
|
} finally { setWarnSaving(false) }
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading…</div>
|
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading…</div>
|
||||||
}
|
}
|
||||||
|
|
@ -380,6 +410,44 @@ export function Settings() {
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Warning Thresholds ───────────────────────────────────────────────── */}
|
||||||
|
<Divider />
|
||||||
|
<SectionHeading title="Warning Thresholds" />
|
||||||
|
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
|
Controls the colour and icon shown on the "with Adjustments" row in the staff rota. All values are in hours.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{warnError && <Banner type="error">{warnError}</Banner>}
|
||||||
|
{warnMsg && <Banner type="ok">{warnMsg}</Banner>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem', marginBottom: '1.25rem', maxWidth: '420px' }}>
|
||||||
|
{([
|
||||||
|
{ label: '⚠ Red warning — spare over', value: warnOverRed, set: setWarnOverRed, help: 'default 4h' },
|
||||||
|
{ label: '⚠ Amber warning — spare over', value: warnOverAmber, set: setWarnOverAmber, help: 'default 1h' },
|
||||||
|
{ label: '✗ Amber cross — short over', value: warnUnderAmber, set: setWarnUnderAmber, help: 'default 1h' },
|
||||||
|
{ label: '✗ Red cross — short over', value: warnUnderRed, set: setWarnUnderRed, help: 'default 2h' },
|
||||||
|
] as { label: string; value: number; set: (v: number) => void; help: string }[]).map(({ label, value, set, help }) => (
|
||||||
|
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||||
|
<span style={{ flex: 1, fontSize: '0.875rem', color: 'var(--text-dark)' }}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="number" className="hk-num-input"
|
||||||
|
min={0} max={24} step={0.5}
|
||||||
|
value={value}
|
||||||
|
onChange={e => set(parseFloat(e.target.value) || 0)}
|
||||||
|
style={{ width: '68px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', minWidth: '52px' }}>{help}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '0.78rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
||||||
|
Green ✓ is automatic — shown when the difference is within the amber spare and short thresholds above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '2.5rem' }}>
|
||||||
|
<Btn onClick={saveWarnThresholds} disabled={warnSaving} primary>{warnSaving ? 'Saving…' : 'Save Thresholds'}</Btn>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue