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
|
|
@ -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'
|
||||
|
||||
|
|
@ -8,7 +8,6 @@ export interface ConfigData {
|
|||
pickup_data: PickupData
|
||||
general_tasks: GeneralTask[]
|
||||
last_reviewed: string
|
||||
workforce_rota: WorkforceRota | null
|
||||
workforce_departments: string[]
|
||||
adjustments: Adjustment[]
|
||||
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> {
|
||||
return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' })
|
||||
export function syncWorkforce(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
|
||||
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 }[]> {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||
import { RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
getBookings, getConfig, putStaff, putPickup,
|
||||
putLastReviewed, putAdjustments, syncWorkforceRota, getWorkforceStaff,
|
||||
putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff,
|
||||
} from '../api'
|
||||
import type {
|
||||
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
||||
PickupData, RequiredDay, DayData, WorkforceRota, Adjustment,
|
||||
PickupData, RequiredDay, DayData, WfDayData, WfStaffMember, Adjustment,
|
||||
} from '../types'
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -133,7 +133,7 @@ export function Planner() {
|
|||
const [warnOverAmber, setWarnOverAmber] = useState(1)
|
||||
const [warnUnderAmber, setWarnUnderAmber] = useState(1)
|
||||
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 [syncing, setSyncing] = useState(false)
|
||||
const [weekStart, setWeekStart] = useState<string | null>(null)
|
||||
|
|
@ -171,10 +171,15 @@ export function Planner() {
|
|||
setLoading(true)
|
||||
setError('')
|
||||
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))
|
||||
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)
|
||||
setTimeReqs(cfg.time_requirements || {})
|
||||
setStaff(cfg.staff_data || [])
|
||||
|
|
@ -185,7 +190,7 @@ export function Planner() {
|
|||
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)
|
||||
setWfShifts(shifts)
|
||||
if (cfg.last_reviewed) {
|
||||
setSavedLastReviewed(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
|
||||
|
||||
// Lazily load WF staff list for datalist once a rota snapshot exists
|
||||
// Lazily load WF staff list for datalist once any shifts exist
|
||||
useEffect(() => {
|
||||
if (workforceRota && !wfStaffLoaded.current) {
|
||||
if (Object.keys(wfShifts).length > 0 && !wfStaffLoaded.current) {
|
||||
wfStaffLoaded.current = true
|
||||
getWorkforceStaff().then(setWfStaff).catch(() => {})
|
||||
}
|
||||
}, [workforceRota])
|
||||
}, [wfShifts])
|
||||
|
||||
// Beacon save on unload
|
||||
useEffect(() => {
|
||||
|
|
@ -291,11 +296,13 @@ export function Planner() {
|
|||
// ── Workforce sync ───────────────────────────────────────────────────────────
|
||||
|
||||
async function syncRota() {
|
||||
if (!bookings) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const rota = await syncWorkforceRota(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
|
||||
setWorkforceRota(rota)
|
||||
await syncWorkforce()
|
||||
if (bookings) {
|
||||
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
|
||||
setWfShifts(shifts)
|
||||
}
|
||||
flash('Rota synced from Workforce')
|
||||
} catch (e) {
|
||||
flash(e instanceof Error ? e.message : 'Sync failed', true)
|
||||
|
|
@ -305,14 +312,17 @@ export function Planner() {
|
|||
}
|
||||
|
||||
function wfSyncLabel(): string {
|
||||
if (!workforceRota) return 'Never synced'
|
||||
const d = new Date(workforceRota.last_sync).toLocaleDateString('en-GB', {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
const matchesWeek = bookings &&
|
||||
workforceRota.dates[0] === bookings.dates[0] &&
|
||||
workforceRota.dates[1] === bookings.dates[bookings.dates.length - 1]
|
||||
return matchesWeek ? `Synced ${d}` : `Synced ${d} — different week`
|
||||
if (!bookings) return ''
|
||||
const viewDates = bookings.dates.filter(d => wfShifts[d])
|
||||
if (!viewDates.length) return 'Not synced'
|
||||
const oldest = viewDates.reduce((min, d) =>
|
||||
new Date(wfShifts[d].synced_at) < new Date(wfShifts[min].synced_at) ? d : min
|
||||
)
|
||||
const dt = new Date(wfShifts[oldest].synced_at)
|
||||
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) ────────────────────────────
|
||||
|
|
@ -462,7 +472,7 @@ export function Planner() {
|
|||
warnUnderAmber={warnUnderAmber}
|
||||
warnUnderRed={warnUnderRed}
|
||||
onChange={handleStaffChange}
|
||||
workforceRota={workforceRota}
|
||||
wfShifts={wfShifts}
|
||||
wfStaff={wfStaff}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -786,7 +796,20 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
|||
|
||||
// ── 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
|
||||
staff: StaffMember[]
|
||||
required: Record<string, RequiredDay>
|
||||
|
|
@ -795,7 +818,7 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
|
|||
warnUnderAmber: number
|
||||
warnUnderRed: number
|
||||
onChange: (next: StaffMember[]) => void
|
||||
workforceRota: WorkforceRota | null
|
||||
wfShifts: Record<string, WfDayData>
|
||||
wfStaff: { id: string; name: string }[]
|
||||
}) {
|
||||
const { dates } = bookings
|
||||
|
|
@ -821,7 +844,7 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
|
|||
onChange(staff.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
const rotaMembers = workforceRota?.staff ?? []
|
||||
const rotaMembers = pivotWfShifts(wfShifts, dates)
|
||||
|
||||
return (
|
||||
<table className="hk-table">
|
||||
|
|
|
|||
|
|
@ -54,21 +54,21 @@ export interface Adjustment {
|
|||
hours: Record<string, number> // YYYY-MM-DD → positive or negative hours
|
||||
}
|
||||
|
||||
export interface WorkforceShiftDay {
|
||||
export interface WfShiftDay {
|
||||
hours: number
|
||||
times: string
|
||||
}
|
||||
|
||||
export interface WorkforceRotaMember {
|
||||
export interface WfStaffMember {
|
||||
id: string
|
||||
name: string
|
||||
days: Record<string, WorkforceShiftDay>
|
||||
days: Record<string, WfShiftDay>
|
||||
}
|
||||
|
||||
export interface WorkforceRota {
|
||||
last_sync: string
|
||||
dates: [string, string]
|
||||
staff: WorkforceRotaMember[]
|
||||
export interface WfDayData {
|
||||
synced_at: string
|
||||
source: string
|
||||
staff: { id: string; name: string; hours: number; times: string }[]
|
||||
}
|
||||
|
||||
export interface RequiredDay {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue