Rework workforce sync to per-date storage with rolling window
Replaces the single workforce_rota config snapshot (which was overwritten on each sync, losing data when switching weeks) with a workforce_daily_shifts table keyed by date. Sync now covers a rolling today-7 to today+28 window unconditionally — no date params needed. Each date's record carries a synced_at timestamp so the UI shows the age of the oldest date in view. Mid-week viewing works naturally since data is stored per date not per week. source column reserved for future timesheet replacement of past dates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a8d8d9a2b
commit
c7066521ff
10 changed files with 278 additions and 178 deletions
|
|
@ -10,6 +10,14 @@ export async function initDb() {
|
||||||
value JSONB NOT NULL DEFAULT 'null'::jsonb
|
value JSONB NOT NULL DEFAULT 'null'::jsonb
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workforce_daily_shifts (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
source TEXT NOT NULL DEFAULT 'workforce',
|
||||||
|
staff JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||||
|
)
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getConfig(key, defaultVal = null) {
|
export async function getConfig(key, defaultVal = null) {
|
||||||
|
|
@ -24,3 +32,38 @@ export async function setConfig(key, value) {
|
||||||
[key, JSON.stringify(value)]
|
[key, JSON.stringify(value)]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWorkforceShifts(start, end) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT date, synced_at, source, staff FROM workforce_daily_shifts
|
||||||
|
WHERE date >= $1 AND date <= $2 ORDER BY date`,
|
||||||
|
[start, end]
|
||||||
|
)
|
||||||
|
const result = {}
|
||||||
|
for (const row of rows) {
|
||||||
|
result[row.date] = { synced_at: row.synced_at, source: row.source, staff: row.staff }
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertWorkforceShifts(dailyRows) {
|
||||||
|
if (!dailyRows.length) return
|
||||||
|
const client = await pool.connect()
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN')
|
||||||
|
for (const row of dailyRows) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO workforce_daily_shifts (date, synced_at, source, staff)
|
||||||
|
VALUES ($1, now(), 'workforce', $2::jsonb)
|
||||||
|
ON CONFLICT (date) DO UPDATE SET synced_at = now(), staff = EXCLUDED.staff`,
|
||||||
|
[row.date, JSON.stringify(row.staff)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await client.query('COMMIT')
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK')
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
client.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export async function configRoutes(app) {
|
||||||
app.get('/api/config', async (req) => {
|
app.get('/api/config', async (req) => {
|
||||||
const [
|
const [
|
||||||
timeReqs, staffData, pickupData, generalTasks, lastReviewed,
|
timeReqs, staffData, pickupData, generalTasks, lastReviewed,
|
||||||
workforceRota, workforceDepts, adjustments,
|
workforceDepts, adjustments,
|
||||||
warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed,
|
warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getConfig('time_requirements', {}),
|
getConfig('time_requirements', {}),
|
||||||
|
|
@ -18,7 +18,6 @@ export async function configRoutes(app) {
|
||||||
getConfig('pickup_data', {}),
|
getConfig('pickup_data', {}),
|
||||||
getConfig('general_tasks', []),
|
getConfig('general_tasks', []),
|
||||||
getConfig('last_reviewed', null),
|
getConfig('last_reviewed', null),
|
||||||
getConfig('workforce_rota', null),
|
|
||||||
getConfig('workforce_departments', []),
|
getConfig('workforce_departments', []),
|
||||||
getConfig('adjustments', []),
|
getConfig('adjustments', []),
|
||||||
getConfig('warn_over_red_hrs', 4),
|
getConfig('warn_over_red_hrs', 4),
|
||||||
|
|
@ -39,7 +38,6 @@ 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,
|
||||||
workforce_rota: workforceRota || null,
|
|
||||||
workforce_departments: workforceDepts || [],
|
workforce_departments: workforceDepts || [],
|
||||||
adjustments: adjustments || [],
|
adjustments: adjustments || [],
|
||||||
warn_over_red_hrs: warnOverRed != null ? warnOverRed : 4,
|
warn_over_red_hrs: warnOverRed != null ? warnOverRed : 4,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { requireAuth, requireCap } from '../auth.js'
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
import { getConfig, setConfig } from '../db.js'
|
import { getConfig, setConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
|
||||||
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
|
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function workforceRoutes(app) {
|
export async function workforceRoutes(app) {
|
||||||
app.addHook('preHandler', requireAuth)
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
// ── GET /api/workforce/departments — list depts for this location ────────────
|
// ── GET /api/workforce/departments ───────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
|
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -16,29 +20,58 @@ export async function workforceRoutes(app) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── POST /api/workforce/sync?start=&end= ─────────────────────────────────────
|
// ── POST /api/workforce/sync — rolling window: today-7 → today+28 ───────────
|
||||||
|
|
||||||
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
|
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||||
const { start, end } = req.query
|
|
||||||
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
|
|
||||||
|
|
||||||
const deptIds = (await getConfig('workforce_departments', [])) || []
|
const deptIds = (await getConfig('workforce_departments', [])) || []
|
||||||
if (!deptIds.length) {
|
if (!deptIds.length) {
|
||||||
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
|
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
|
||||||
|
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
|
||||||
|
const from = fmtDate(fromDate)
|
||||||
|
const to = fmtDate(toDate)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const staff = await fetchShifts(start, end, deptIds)
|
const staffList = await fetchShifts(from, to, deptIds)
|
||||||
const snapshot = { last_sync: new Date().toISOString(), dates: [start, end], staff }
|
|
||||||
await setConfig('workforce_rota', snapshot)
|
// Pivot per-member → per-date
|
||||||
return snapshot
|
const byDate = {}
|
||||||
|
for (const member of staffList) {
|
||||||
|
for (const [date, shift] of Object.entries(member.days)) {
|
||||||
|
if (!byDate[date]) byDate[date] = []
|
||||||
|
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a row for every date in the window (empty staff = no one scheduled)
|
||||||
|
const dailyRows = []
|
||||||
|
const cur = new Date(fromDate)
|
||||||
|
while (cur <= toDate) {
|
||||||
|
const d = fmtDate(cur)
|
||||||
|
dailyRows.push({ date: d, staff: byDate[d] || [] })
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertWorkforceShifts(dailyRows)
|
||||||
|
return { ok: true, from, to, dates_synced: dailyRows.length }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err.message.includes('not configured') ? 503 : 502
|
const status = err.message.includes('not configured') ? 503 : 502
|
||||||
return reply.status(status).send({ error: err.message })
|
return reply.status(status).send({ error: err.message })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GET /api/workforce/staff — staff list for manual row datalist ─────────────
|
// ── GET /api/workforce/shifts?start=&end= ────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/workforce/shifts', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||||
|
const { start, end } = req.query
|
||||||
|
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
|
||||||
|
return getWorkforceShifts(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── GET /api/workforce/staff — cached staff list for manual row datalist ─────
|
||||||
|
|
||||||
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
|
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||||
const deptIds = (await getConfig('workforce_departments', [])) || []
|
const deptIds = (await getConfig('workforce_departments', [])) || []
|
||||||
|
|
|
||||||
127
frontend/dist/assets/index-BM62SqjA.js
vendored
Normal file
127
frontend/dist/assets/index-BM62SqjA.js
vendored
Normal file
File diff suppressed because one or more lines are too long
127
frontend/dist/assets/index-DOMhnWTP.js
vendored
127
frontend/dist/assets/index-DOMhnWTP.js
vendored
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-DOMhnWTP.js"></script>
|
<script type="module" crossorigin src="/hk-planner/assets/index-BM62SqjA.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:"dac594c6fb03224d7ef693607551dd8a"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-DOMhnWTP.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),l={module:{uri:o},exports:t,require:c};i[o]=Promise.all(s.map(e=>l[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:"46079111fca52b3927026fe671b56a2f"},{url:"icons/icon-512.png",revision:"c32202b9deed67ef38331f63dec9d1c8"},{url:"icons/icon-192.png",revision:"46ece317d50d10b8f5e225e073b3221d"},{url:"assets/index-BM62SqjA.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\//]}))});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota, Adjustment } from './types'
|
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WfDayData, Adjustment } from './types'
|
||||||
|
|
||||||
const BASE = '/hk-planner/api'
|
const BASE = '/hk-planner/api'
|
||||||
|
|
||||||
|
|
@ -8,7 +8,6 @@ export interface ConfigData {
|
||||||
pickup_data: PickupData
|
pickup_data: PickupData
|
||||||
general_tasks: GeneralTask[]
|
general_tasks: GeneralTask[]
|
||||||
last_reviewed: string
|
last_reviewed: string
|
||||||
workforce_rota: WorkforceRota | null
|
|
||||||
workforce_departments: string[]
|
workforce_departments: string[]
|
||||||
adjustments: Adjustment[]
|
adjustments: Adjustment[]
|
||||||
warn_over_red_hrs: number
|
warn_over_red_hrs: number
|
||||||
|
|
@ -117,8 +116,12 @@ export function putWorkforceDepartments(dept_ids: string[]): Promise<{ ok: boole
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncWorkforceRota(start: string, end: string): Promise<WorkforceRota> {
|
export function syncWorkforce(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
|
||||||
return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' })
|
return request('/workforce/sync', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> {
|
||||||
|
return request(`/workforce/shifts?start=${start}&end=${end}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
|
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getBookings, getConfig, putStaff, putPickup,
|
getBookings, getConfig, putStaff, putPickup,
|
||||||
putLastReviewed, putAdjustments, syncWorkforceRota, getWorkforceStaff,
|
putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type {
|
import type {
|
||||||
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
||||||
PickupData, RequiredDay, DayData, WorkforceRota, Adjustment,
|
PickupData, RequiredDay, DayData, WfDayData, WfStaffMember, Adjustment,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -133,7 +133,7 @@ export function Planner() {
|
||||||
const [warnOverAmber, setWarnOverAmber] = useState(1)
|
const [warnOverAmber, setWarnOverAmber] = useState(1)
|
||||||
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
|
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
|
||||||
const [warnUnderRed, setWarnUnderRed] = useState(2)
|
const [warnUnderRed, setWarnUnderRed] = useState(2)
|
||||||
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
const [wfShifts, setWfShifts] = useState<Record<string, WfDayData>>({})
|
||||||
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)
|
||||||
const [weekStart, setWeekStart] = useState<string | null>(null)
|
const [weekStart, setWeekStart] = useState<string | null>(null)
|
||||||
|
|
@ -171,10 +171,15 @@ export function Planner() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
const today = todayStr()
|
const today = todayStr()
|
||||||
const ws = weekStartRef.current || today
|
const ws = weekStartRef.current || today
|
||||||
|
const wsEnd = offsetDate(ws, 6)
|
||||||
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, shifts] = await Promise.all([
|
||||||
|
getBookings(ws, lv, force),
|
||||||
|
getConfig(),
|
||||||
|
getWorkforceShifts(ws, wsEnd).catch(() => ({} as Record<string, WfDayData>)),
|
||||||
|
])
|
||||||
setBookings(b)
|
setBookings(b)
|
||||||
setTimeReqs(cfg.time_requirements || {})
|
setTimeReqs(cfg.time_requirements || {})
|
||||||
setStaff(cfg.staff_data || [])
|
setStaff(cfg.staff_data || [])
|
||||||
|
|
@ -185,7 +190,7 @@ export function Planner() {
|
||||||
setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1)
|
setWarnOverAmber(cfg.warn_over_amber_hrs ?? 1)
|
||||||
setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1)
|
setWarnUnderAmber(cfg.warn_under_amber_hrs ?? 1)
|
||||||
setWarnUnderRed(cfg.warn_under_red_hrs ?? 2)
|
setWarnUnderRed(cfg.warn_under_red_hrs ?? 2)
|
||||||
setWorkforceRota(cfg.workforce_rota || null)
|
setWfShifts(shifts)
|
||||||
if (cfg.last_reviewed) {
|
if (cfg.last_reviewed) {
|
||||||
setSavedLastReviewed(cfg.last_reviewed)
|
setSavedLastReviewed(cfg.last_reviewed)
|
||||||
if (!lastViewed) setLastViewed(cfg.last_reviewed)
|
if (!lastViewed) setLastViewed(cfg.last_reviewed)
|
||||||
|
|
@ -199,13 +204,13 @@ export function Planner() {
|
||||||
|
|
||||||
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Lazily load WF staff list for datalist once a rota snapshot exists
|
// Lazily load WF staff list for datalist once any shifts exist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workforceRota && !wfStaffLoaded.current) {
|
if (Object.keys(wfShifts).length > 0 && !wfStaffLoaded.current) {
|
||||||
wfStaffLoaded.current = true
|
wfStaffLoaded.current = true
|
||||||
getWorkforceStaff().then(setWfStaff).catch(() => {})
|
getWorkforceStaff().then(setWfStaff).catch(() => {})
|
||||||
}
|
}
|
||||||
}, [workforceRota])
|
}, [wfShifts])
|
||||||
|
|
||||||
// Beacon save on unload
|
// Beacon save on unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -291,11 +296,13 @@ export function Planner() {
|
||||||
// ── Workforce sync ───────────────────────────────────────────────────────────
|
// ── Workforce sync ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function syncRota() {
|
async function syncRota() {
|
||||||
if (!bookings) return
|
|
||||||
setSyncing(true)
|
setSyncing(true)
|
||||||
try {
|
try {
|
||||||
const rota = await syncWorkforceRota(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
|
await syncWorkforce()
|
||||||
setWorkforceRota(rota)
|
if (bookings) {
|
||||||
|
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
|
||||||
|
setWfShifts(shifts)
|
||||||
|
}
|
||||||
flash('Rota synced from Workforce')
|
flash('Rota synced from Workforce')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
flash(e instanceof Error ? e.message : 'Sync failed', true)
|
flash(e instanceof Error ? e.message : 'Sync failed', true)
|
||||||
|
|
@ -305,14 +312,17 @@ export function Planner() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function wfSyncLabel(): string {
|
function wfSyncLabel(): string {
|
||||||
if (!workforceRota) return 'Never synced'
|
if (!bookings) return ''
|
||||||
const d = new Date(workforceRota.last_sync).toLocaleDateString('en-GB', {
|
const viewDates = bookings.dates.filter(d => wfShifts[d])
|
||||||
weekday: 'short', day: 'numeric', month: 'short',
|
if (!viewDates.length) return 'Not synced'
|
||||||
})
|
const oldest = viewDates.reduce((min, d) =>
|
||||||
const matchesWeek = bookings &&
|
new Date(wfShifts[d].synced_at) < new Date(wfShifts[min].synced_at) ? d : min
|
||||||
workforceRota.dates[0] === bookings.dates[0] &&
|
)
|
||||||
workforceRota.dates[1] === bookings.dates[bookings.dates.length - 1]
|
const dt = new Date(wfShifts[oldest].synced_at)
|
||||||
return matchesWeek ? `Synced ${d}` : `Synced ${d} — different week`
|
const day = dt.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||||
|
const time = dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
const missing = bookings.dates.filter(d => !wfShifts[d]).length
|
||||||
|
return missing ? `Partial sync — from ${day} ${time}` : `From ${day} ${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Required hours (memoised on state changes) ────────────────────────────
|
// ── Required hours (memoised on state changes) ────────────────────────────
|
||||||
|
|
@ -462,7 +472,7 @@ export function Planner() {
|
||||||
warnUnderAmber={warnUnderAmber}
|
warnUnderAmber={warnUnderAmber}
|
||||||
warnUnderRed={warnUnderRed}
|
warnUnderRed={warnUnderRed}
|
||||||
onChange={handleStaffChange}
|
onChange={handleStaffChange}
|
||||||
workforceRota={workforceRota}
|
wfShifts={wfShifts}
|
||||||
wfStaff={wfStaff}
|
wfStaff={wfStaff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -786,7 +796,20 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
||||||
|
|
||||||
// ── Staff Table ───────────────────────────────────────────────────────────────
|
// ── Staff Table ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, workforceRota, wfStaff }: {
|
function pivotWfShifts(wfShifts: Record<string, WfDayData>, dates: string[]): WfStaffMember[] {
|
||||||
|
const members: Record<string, WfStaffMember> = {}
|
||||||
|
for (const date of dates) {
|
||||||
|
const day = wfShifts[date]
|
||||||
|
if (!day) continue
|
||||||
|
for (const s of day.staff) {
|
||||||
|
if (!members[s.id]) members[s.id] = { id: s.id, name: s.name, days: {} }
|
||||||
|
members[s.id].days[date] = { hours: s.hours, times: s.times }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.values(members).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed, onChange, wfShifts, wfStaff }: {
|
||||||
bookings: BookingsData
|
bookings: BookingsData
|
||||||
staff: StaffMember[]
|
staff: StaffMember[]
|
||||||
required: Record<string, RequiredDay>
|
required: Record<string, RequiredDay>
|
||||||
|
|
@ -795,7 +818,7 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
|
||||||
warnUnderAmber: number
|
warnUnderAmber: number
|
||||||
warnUnderRed: number
|
warnUnderRed: number
|
||||||
onChange: (next: StaffMember[]) => void
|
onChange: (next: StaffMember[]) => void
|
||||||
workforceRota: WorkforceRota | null
|
wfShifts: Record<string, WfDayData>
|
||||||
wfStaff: { id: string; name: string }[]
|
wfStaff: { id: string; name: string }[]
|
||||||
}) {
|
}) {
|
||||||
const { dates } = bookings
|
const { dates } = bookings
|
||||||
|
|
@ -821,7 +844,7 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
|
||||||
onChange(staff.filter((_, idx) => idx !== i))
|
onChange(staff.filter((_, idx) => idx !== i))
|
||||||
}
|
}
|
||||||
|
|
||||||
const rotaMembers = workforceRota?.staff ?? []
|
const rotaMembers = pivotWfShifts(wfShifts, dates)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="hk-table">
|
<table className="hk-table">
|
||||||
|
|
|
||||||
|
|
@ -54,21 +54,21 @@ export interface Adjustment {
|
||||||
hours: Record<string, number> // YYYY-MM-DD → positive or negative hours
|
hours: Record<string, number> // YYYY-MM-DD → positive or negative hours
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkforceShiftDay {
|
export interface WfShiftDay {
|
||||||
hours: number
|
hours: number
|
||||||
times: string
|
times: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkforceRotaMember {
|
export interface WfStaffMember {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
days: Record<string, WorkforceShiftDay>
|
days: Record<string, WfShiftDay>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkforceRota {
|
export interface WfDayData {
|
||||||
last_sync: string
|
synced_at: string
|
||||||
dates: [string, string]
|
source: string
|
||||||
staff: WorkforceRotaMember[]
|
staff: { id: string; name: string; hours: number; times: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequiredDay {
|
export interface RequiredDay {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue