Fix week navigation using ref to eliminate stale closure entirely
Previous wsArg approach still suffered from useCallback memoisation interacting with React batching — loadAll could fire with the render- before-last week start. Switch to weekStartRef (written before every loadAll call and on every render) so loadAll always reads the live value regardless of when the memoised function was created. Also removes unused putWarningThresholds import (added externally). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f1ba176195
commit
fee388db4c
6 changed files with 219 additions and 176 deletions
|
|
@ -1 +1 @@
|
||||||
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--navy: #1a1a2e;--navy-dark: #0f0f20;--gold: #c9a84c;--gold-light: #e8c96d;--surface: rgba(255,255,255,.07);--surface-2: rgba(255,255,255,.08);--text: rgba(255,255,255,.88);--text-muted: rgba(255,255,255,.48);--body-bg: #f4f5f7;--card-bg: #ffffff;--card-border: #e4e8ee;--text-dark: #1e293b;--text-mid: #64748b;--shadow-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);--shadow-md: 0 4px 12px rgba(0,0,0,.08);--danger: #dc2626;--success: #16a34a;--warning: #d97706;--hk-green: #2d6a4f;--radius: 10px;--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif}body{background:var(--body-bg);color:var(--text-dark);font-family:var(--font);min-height:100dvh}button{cursor:pointer;font-family:inherit}input,textarea,select{font-family:inherit}.hk-table{width:100%;border-collapse:collapse;font-size:.82rem;table-layout:fixed}.hk-table th{background:var(--navy);color:var(--text);padding:.45rem .5rem;text-align:center;font-weight:600;white-space:nowrap}.hk-table th.col-label{text-align:left;width:140px}.hk-table td{padding:.35rem .5rem;border-bottom:1px solid var(--card-border);text-align:center;vertical-align:middle}.hk-table td.col-label{text-align:left;font-weight:600;color:var(--text-dark);background:var(--card-bg)}.hk-table tfoot td{font-weight:600;background:#f8fafc}.row-depart{background:#fff8f0}.row-stay{background:#f0f7ff}.row-arrive{background:#f0fff5}.row-depart td,.row-stay td,.row-arrive td{border-bottom:none}.row-group-last td{border-bottom:2px solid var(--card-border)!important}.pickup-tag{color:var(--hk-green);font-size:.75rem;font-weight:600}.delta-new{color:var(--success);font-size:.72rem}.delta-canc{color:var(--danger);font-size:.72rem}.pickup-ctrl{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:2px}.pickup-btn{background:none;border:1px solid var(--card-border);border-radius:3px;padding:0 5px;line-height:1.4;font-size:.8rem;color:var(--text-mid)}.pickup-btn:disabled{opacity:.35;cursor:not-allowed}.pickup-num{min-width:16px;text-align:center;font-size:.8rem;font-weight:600}.diff-ok{color:var(--success);font-weight:600}.diff-under{color:var(--danger);font-weight:600}.diff-over{color:var(--warning);font-weight:600}.hk-num-input{width:60px;border:1px solid var(--card-border);border-radius:4px;padding:.3rem .4rem;font-size:.82rem;text-align:center;background:var(--body-bg);color:var(--text-dark)}.hk-num-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.hk-text-input{border:1px solid var(--card-border);border-radius:4px;padding:.3rem .5rem;font-size:.82rem;background:var(--body-bg);color:var(--text-dark);width:100%}.hk-text-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.table-scroll{overflow-x:auto;border-radius:var(--radius);border:1px solid var(--card-border);box-shadow:var(--shadow-sm)}.nav-scroll::-webkit-scrollbar,.sidebar::-webkit-scrollbar,.sidebar-nav::-webkit-scrollbar{width:4px}.nav-scroll::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track,.sidebar-nav::-webkit-scrollbar-track{background:transparent}.nav-scroll::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb,.sidebar-nav::-webkit-scrollbar-thumb{background:#c9a84c59;border-radius:2px}.nav-scroll::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover,.sidebar-nav::-webkit-scrollbar-thumb:hover{background:#c9a84ca6}.nav-scroll,.sidebar,.sidebar-nav{scrollbar-width:thin;scrollbar-color:rgba(201,168,76,.35) transparent}
|
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--navy: #1a1a2e;--navy-dark: #0f0f20;--gold: #c9a84c;--gold-light: #e8c96d;--surface: rgba(255,255,255,.07);--surface-2: rgba(255,255,255,.08);--text: rgba(255,255,255,.88);--text-muted: rgba(255,255,255,.48);--body-bg: #f4f5f7;--card-bg: #ffffff;--card-border: #e4e8ee;--text-dark: #1e293b;--text-mid: #64748b;--shadow-sm: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.04);--shadow-md: 0 4px 12px rgba(0,0,0,.08);--danger: #dc2626;--success: #16a34a;--warning: #d97706;--hk-green: #2d6a4f;--radius: 10px;--font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif}body{background:var(--body-bg);color:var(--text-dark);font-family:var(--font);min-height:100dvh}button{cursor:pointer;font-family:inherit}input,textarea,select{font-family:inherit}.hk-table{width:100%;border-collapse:collapse;font-size:.82rem;table-layout:fixed}.hk-table th{background:var(--navy);color:var(--text);padding:.45rem .5rem;text-align:center;font-weight:600;white-space:nowrap}.hk-table th.col-label{text-align:left;width:140px}.hk-table td{padding:.35rem .5rem;border-bottom:1px solid var(--card-border);text-align:center;vertical-align:middle}.hk-table td.col-label{text-align:left;font-weight:600;color:var(--text-dark);background:var(--card-bg)}.hk-table tfoot td{font-weight:600;background:#f8fafc}.row-depart{background:#fff8f0}.row-stay{background:#f0f7ff}.row-arrive{background:#f0fff5}.row-depart td,.row-stay td,.row-arrive td{border-bottom:none}.row-group-last td{border-bottom:2px solid var(--card-border)!important}.pickup-tag{color:var(--hk-green);font-size:.75rem;font-weight:600}.delta-new{color:var(--success);font-size:.72rem}.delta-canc{color:var(--danger);font-size:.72rem}.pickup-ctrl{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:2px}.pickup-btn{background:none;border:1px solid var(--card-border);border-radius:3px;padding:0 5px;line-height:1.4;font-size:.8rem;color:var(--text-mid)}.pickup-btn:disabled{opacity:.35;cursor:not-allowed}.pickup-num{min-width:16px;text-align:center;font-size:.8rem;font-weight:600}.diff-ok{color:var(--success);font-weight:600}.diff-under{color:var(--danger);font-weight:600}.diff-under-amber,.diff-over{color:var(--warning);font-weight:600}.diff-over-red{color:var(--danger);font-weight:600}.hk-num-input{width:60px;border:1px solid var(--card-border);border-radius:4px;padding:.3rem .4rem;font-size:.82rem;text-align:center;background:var(--body-bg);color:var(--text-dark)}.hk-num-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.hk-text-input{border:1px solid var(--card-border);border-radius:4px;padding:.3rem .5rem;font-size:.82rem;background:var(--body-bg);color:var(--text-dark);width:100%}.hk-text-input:focus{outline:2px solid var(--hk-green);border-color:transparent}.table-scroll{overflow-x:auto;border-radius:var(--radius);border:1px solid var(--card-border);box-shadow:var(--shadow-sm)}.nav-scroll::-webkit-scrollbar,.sidebar::-webkit-scrollbar,.sidebar-nav::-webkit-scrollbar{width:4px}.nav-scroll::-webkit-scrollbar-track,.sidebar::-webkit-scrollbar-track,.sidebar-nav::-webkit-scrollbar-track{background:transparent}.nav-scroll::-webkit-scrollbar-thumb,.sidebar::-webkit-scrollbar-thumb,.sidebar-nav::-webkit-scrollbar-thumb{background:#c9a84c59;border-radius:2px}.nav-scroll::-webkit-scrollbar-thumb:hover,.sidebar::-webkit-scrollbar-thumb:hover,.sidebar-nav::-webkit-scrollbar-thumb:hover{background:#c9a84ca6}.nav-scroll,.sidebar,.sidebar-nav{scrollbar-width:thin;scrollbar-color:rgba(201,168,76,.35) transparent}
|
||||||
127
frontend/dist/assets/index-CG3D4SXd.js
vendored
Normal file
127
frontend/dist/assets/index-CG3D4SXd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
127
frontend/dist/assets/index-IImb5HHO.js
vendored
127
frontend/dist/assets/index-IImb5HHO.js
vendored
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
|
|
@ -5,8 +5,8 @@
|
||||||
<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-IImb5HHO.js"></script>
|
<script type="module" crossorigin src="/hk-planner/assets/index-CG3D4SXd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/hk-planner/assets/index-BY6qKqNq.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>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
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:"c559453bdaf3e95d6d3ad0625d673085"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-IImb5HHO.js",revision:null},{url:"assets/index-BY6qKqNq.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 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\//]}))});
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,11 @@ export function Planner() {
|
||||||
const [staff, setStaff] = useState<StaffMember[]>([])
|
const [staff, setStaff] = useState<StaffMember[]>([])
|
||||||
const [pickup, setPickup] = useState<PickupData>({})
|
const [pickup, setPickup] = useState<PickupData>({})
|
||||||
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
|
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
|
||||||
const [tolerance, setTolerance] = useState(30)
|
|
||||||
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
|
const [adjustments, setAdjustments] = useState<Adjustment[]>([])
|
||||||
|
const [warnOverRed, setWarnOverRed] = useState(4)
|
||||||
|
const [warnOverAmber, setWarnOverAmber] = useState(1)
|
||||||
|
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
|
||||||
|
const [warnUnderRed, setWarnUnderRed] = useState(2)
|
||||||
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
||||||
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
|
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
|
|
@ -143,6 +146,8 @@ export function Planner() {
|
||||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||||
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const wfStaffLoaded = useRef(false)
|
const wfStaffLoaded = useRef(false)
|
||||||
|
const weekStartRef = useRef<string | null>(null)
|
||||||
|
weekStartRef.current = weekStart // always current, updated every render
|
||||||
|
|
||||||
function debounce(key: string, fn: () => void, delay = 400) {
|
function debounce(key: string, fn: () => void, delay = 400) {
|
||||||
clearTimeout(timers.current[key])
|
clearTimeout(timers.current[key])
|
||||||
|
|
@ -162,11 +167,11 @@ export function Planner() {
|
||||||
putLastReviewed(today).catch(() => {})
|
putLastReviewed(today).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAll = useCallback(async (force = false, wsArg?: string | null, lvArg?: string) => {
|
const loadAll = useCallback(async (force = false, lvArg?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
const ws = wsArg !== undefined ? (wsArg ?? today) : (weekStart || today)
|
const ws = weekStartRef.current || today
|
||||||
const lv = lvArg !== undefined ? (lvArg || offsetDate(today, -1)) : (lastViewed || savedLastReviewed || offsetDate(today, -1))
|
const lv = lvArg !== undefined ? (lvArg || offsetDate(today, -1)) : (lastViewed || savedLastReviewed || offsetDate(today, -1))
|
||||||
try {
|
try {
|
||||||
const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()])
|
const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()])
|
||||||
|
|
@ -175,8 +180,11 @@ export function Planner() {
|
||||||
setStaff(cfg.staff_data || [])
|
setStaff(cfg.staff_data || [])
|
||||||
setPickup(cfg.pickup_data || {})
|
setPickup(cfg.pickup_data || {})
|
||||||
setGeneralTasks(cfg.general_tasks || [])
|
setGeneralTasks(cfg.general_tasks || [])
|
||||||
setTolerance(cfg.tolerance_minutes ?? 30)
|
|
||||||
setAdjustments(cfg.adjustments || [])
|
setAdjustments(cfg.adjustments || [])
|
||||||
|
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)
|
||||||
setWorkforceRota(cfg.workforce_rota || null)
|
setWorkforceRota(cfg.workforce_rota || null)
|
||||||
if (cfg.last_reviewed) {
|
if (cfg.last_reviewed) {
|
||||||
setSavedLastReviewed(cfg.last_reviewed)
|
setSavedLastReviewed(cfg.last_reviewed)
|
||||||
|
|
@ -187,7 +195,7 @@ export function Planner() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [weekStart, lastViewed, savedLastReviewed])
|
}, [lastViewed, savedLastReviewed])
|
||||||
|
|
||||||
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
|
@ -218,16 +226,18 @@ export function Planner() {
|
||||||
|
|
||||||
function handleWeekOffset(days: number) {
|
function handleWeekOffset(days: number) {
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
const newStart = offsetDate(weekStart || today, days)
|
const newStart = offsetDate(weekStartRef.current || today, days)
|
||||||
|
weekStartRef.current = newStart
|
||||||
setWeekStart(newStart)
|
setWeekStart(newStart)
|
||||||
stampLastReviewed()
|
stampLastReviewed()
|
||||||
loadAll(true, newStart)
|
loadAll(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWeekToday() {
|
function handleWeekToday() {
|
||||||
|
weekStartRef.current = null
|
||||||
setWeekStart(null)
|
setWeekStart(null)
|
||||||
stampLastReviewed()
|
stampLastReviewed()
|
||||||
loadAll(true, null)
|
loadAll(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRefresh() {
|
function handleRefresh() {
|
||||||
|
|
@ -338,7 +348,7 @@ export function Planner() {
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={displayWeekStart}
|
value={displayWeekStart}
|
||||||
onChange={e => { if (e.target.value) { const v = e.target.value; setWeekStart(v); stampLastReviewed(); debounce('weekStart', () => loadAll(true, v), 600) } }}
|
onChange={e => { if (e.target.value) { const v = e.target.value; weekStartRef.current = v; setWeekStart(v); stampLastReviewed(); debounce('weekStart', () => loadAll(true), 600) } }}
|
||||||
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }}
|
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }}
|
||||||
/>
|
/>
|
||||||
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
|
<div style={{ width: '1px', height: '24px', background: 'var(--card-border)' }} />
|
||||||
|
|
@ -348,7 +358,7 @@ export function Planner() {
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={lastViewed}
|
value={lastViewed}
|
||||||
onChange={e => { if (e.target.value) { const v = e.target.value; setLastViewed(v); debounce('lastViewed', () => loadAll(true, undefined, v), 600) } }}
|
onChange={e => { if (e.target.value) { const v = e.target.value; setLastViewed(v); debounce('lastViewed', () => loadAll(true, v), 600) } }}
|
||||||
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' }}
|
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.3rem 0.5rem', fontSize: '0.78rem', color: 'var(--text-dark)' }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -447,7 +457,10 @@ export function Planner() {
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
staff={staff}
|
staff={staff}
|
||||||
required={required}
|
required={required}
|
||||||
tolerance={tolerance}
|
warnOverRed={warnOverRed}
|
||||||
|
warnOverAmber={warnOverAmber}
|
||||||
|
warnUnderAmber={warnUnderAmber}
|
||||||
|
warnUnderRed={warnUnderRed}
|
||||||
onChange={handleStaffChange}
|
onChange={handleStaffChange}
|
||||||
workforceRota={workforceRota}
|
workforceRota={workforceRota}
|
||||||
wfStaff={wfStaff}
|
wfStaff={wfStaff}
|
||||||
|
|
@ -773,17 +786,19 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
||||||
|
|
||||||
// ── Staff Table ───────────────────────────────────────────────────────────────
|
// ── Staff Table ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StaffTable({ bookings, staff, required, tolerance, onChange, workforceRota, wfStaff }: {
|
function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, workforceRota, wfStaff }: {
|
||||||
bookings: BookingsData
|
bookings: BookingsData
|
||||||
staff: StaffMember[]
|
staff: StaffMember[]
|
||||||
required: Record<string, RequiredDay>
|
required: Record<string, RequiredDay>
|
||||||
tolerance: number
|
warnOverRed: number
|
||||||
|
warnOverAmber: number
|
||||||
|
warnUnderAmber: number
|
||||||
|
warnUnderRed: number
|
||||||
onChange: (next: StaffMember[]) => void
|
onChange: (next: StaffMember[]) => void
|
||||||
workforceRota: WorkforceRota | null
|
workforceRota: WorkforceRota | null
|
||||||
wfStaff: { id: string; name: string }[]
|
wfStaff: { id: string; name: string }[]
|
||||||
}) {
|
}) {
|
||||||
const { dates } = bookings
|
const { dates } = bookings
|
||||||
const tolHrs = tolerance / 60
|
|
||||||
|
|
||||||
function setMemberName(i: number, name: string) {
|
function setMemberName(i: number, name: string) {
|
||||||
const next = staff.map((m, idx) => idx === i ? { ...m, name } : m)
|
const next = staff.map((m, idx) => idx === i ? { ...m, name } : m)
|
||||||
|
|
@ -883,44 +898,72 @@ function StaffTable({ bookings, staff, required, tolerance, onChange, workforceR
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
{(() => {
|
||||||
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
|
const avail: Record<string, number> = {}
|
||||||
{dates.map(date => {
|
for (const date of dates) {
|
||||||
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
|
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
|
||||||
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
||||||
return <td key={date}>{fmtH(rotaHrs + manualHrs)}</td>
|
avail[date] = rotaHrs + manualHrs
|
||||||
})}
|
}
|
||||||
<td />
|
return (
|
||||||
</tr>
|
<>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
|
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
|
||||||
{dates.map(date => {
|
{dates.map(date => <td key={date}>{fmtH(avail[date])}</td>)}
|
||||||
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
|
<td />
|
||||||
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
</tr>
|
||||||
const reqHrs = required[date].booked + required[date].general
|
<tr>
|
||||||
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={reqHrs} tolerance={tolHrs} /></td>
|
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
|
||||||
})}
|
{dates.map(date => (
|
||||||
<td />
|
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked} /></td>
|
||||||
</tr>
|
))}
|
||||||
<tr>
|
<td />
|
||||||
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
|
</tr>
|
||||||
{dates.map(date => {
|
<tr>
|
||||||
const rotaHrs = rotaMembers.reduce((s, m) => s + (m.days[date]?.hours || 0), 0)
|
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Recurring Tasks</td>
|
||||||
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
{dates.map(date => (
|
||||||
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={required[date].total} tolerance={tolHrs} /></td>
|
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general} /></td>
|
||||||
})}
|
))}
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>with Pickup</td>
|
||||||
|
{dates.map(date => (
|
||||||
|
<td key={date}><PlainDiffCell available={avail[date]} required={required[date].booked + required[date].general + required[date].pickup} /></td>
|
||||||
|
))}
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
<tr style={{ background: '#e2e8f0' }}>
|
||||||
|
<td className="col-label" style={{ fontWeight: 700 }}>with Adjustments</td>
|
||||||
|
{dates.map(date => (
|
||||||
|
<td key={date}><DiffCell available={avail[date]} required={required[date].total} warnOverRed={warnOverRed} warnOverAmber={warnOverAmber} warnUnderAmber={warnUnderAmber} warnUnderRed={warnUnderRed} /></td>
|
||||||
|
))}
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DiffCell({ available, required, tolerance }: { available: number; required: number; tolerance: number }) {
|
function PlainDiffCell({ available, required }: { available: number; required: number }) {
|
||||||
|
if (available === 0 && required === 0) return <span style={{ color: 'var(--text-mid)' }}>—</span>
|
||||||
const diff = available - required
|
const diff = available - required
|
||||||
if (required === 0 && available === 0) return <span style={{ color: 'var(--text-mid)' }}>—</span>
|
return <span style={{ color: 'var(--text-mid)', fontSize: '0.82rem' }}>{diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
|
||||||
if (diff < -tolerance) return <span className="diff-under">✗ {fmtH(Math.abs(diff))} short</span>
|
}
|
||||||
if (diff > tolerance) return <span className="diff-over">⚠ {fmtH(diff)} spare</span>
|
|
||||||
|
function DiffCell({ available, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed }: {
|
||||||
|
available: number; required: number
|
||||||
|
warnOverRed: number; warnOverAmber: number; warnUnderAmber: number; warnUnderRed: number
|
||||||
|
}) {
|
||||||
|
if (available === 0 && required === 0) return <span style={{ color: 'var(--text-mid)' }}>—</span>
|
||||||
|
const diff = available - required
|
||||||
|
if (diff > warnOverRed) return <span className="diff-over-red">⚠ {fmtH(diff)} spare</span>
|
||||||
|
if (diff > warnOverAmber) return <span className="diff-over">⚠ {fmtH(diff)} spare</span>
|
||||||
|
if (diff < -warnUnderRed) return <span className="diff-under">✗ {fmtH(Math.abs(diff))} short</span>
|
||||||
|
if (diff < -warnUnderAmber) return <span className="diff-under-amber">✗ {fmtH(Math.abs(diff))} short</span>
|
||||||
return <span className="diff-ok">✓ {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
|
return <span className="diff-ok">✓ {diff >= 0 ? '+' : ''}{fmtH(diff)}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue