Add Workforce rota integration to HK Planner
Pulls HK staff shifts from Workforce.com API into the staff rota section. Rota rows (read-only, WF badge, times+hours per day) appear above manual rows; manual name inputs get a datalist populated from WF staff. Sync triggered via button with stale-week detection. Dept selection stored per-app in CategorySettings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
490a9a558b
commit
b803b01ae1
8 changed files with 498 additions and 27 deletions
|
|
@ -2,11 +2,11 @@ import { useState, useEffect, useRef, useCallback } from 'react'
|
|||
import { RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
getBookings, getConfig, putTimeReq, putStaff, putPickup,
|
||||
putGeneralTasks, putLastReviewed,
|
||||
putGeneralTasks, putLastReviewed, syncWorkforceRota, getWorkforceStaff,
|
||||
} from '../api'
|
||||
import type {
|
||||
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
||||
PickupData, RequiredDay, DayData,
|
||||
PickupData, RequiredDay, DayData, WorkforceRota,
|
||||
} from '../types'
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -127,6 +127,9 @@ export function Planner() {
|
|||
const [pickup, setPickup] = useState<PickupData>({})
|
||||
const [generalTasks, setGeneralTasks] = useState<GeneralTask[]>([])
|
||||
const [tolerance, setTolerance] = useState(30)
|
||||
const [workforceRota, setWorkforceRota] = useState<WorkforceRota | null>(null)
|
||||
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [weekStart, setWeekStart] = useState<string | null>(null)
|
||||
const [lastViewed, setLastViewed] = useState('')
|
||||
const [savedLastReviewed, setSavedLastReviewed] = useState('')
|
||||
|
|
@ -136,6 +139,7 @@ export function Planner() {
|
|||
|
||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const saveMsgTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const wfStaffLoaded = useRef(false)
|
||||
|
||||
function debounce(key: string, fn: () => void, delay = 400) {
|
||||
clearTimeout(timers.current[key])
|
||||
|
|
@ -169,6 +173,7 @@ export function Planner() {
|
|||
setPickup(cfg.pickup_data || {})
|
||||
setGeneralTasks(cfg.general_tasks || [])
|
||||
setTolerance(cfg.tolerance_minutes ?? 30)
|
||||
setWorkforceRota(cfg.workforce_rota || null)
|
||||
if (cfg.last_reviewed) {
|
||||
setSavedLastReviewed(cfg.last_reviewed)
|
||||
if (!lastViewed) setLastViewed(cfg.last_reviewed)
|
||||
|
|
@ -182,6 +187,14 @@ 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
|
||||
useEffect(() => {
|
||||
if (workforceRota && !wfStaffLoaded.current) {
|
||||
wfStaffLoaded.current = true
|
||||
getWorkforceStaff().then(setWfStaff).catch(() => {})
|
||||
}
|
||||
}, [workforceRota])
|
||||
|
||||
// Beacon save on unload
|
||||
useEffect(() => {
|
||||
function onUnload() {
|
||||
|
|
@ -274,6 +287,33 @@ export function Planner() {
|
|||
handleTasksChange([...generalTasks, { name: '', hours: {} }])
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
flash('Rota synced from Workforce')
|
||||
} catch (e) {
|
||||
flash(e instanceof Error ? e.message : 'Sync failed', true)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
// ── Required hours (memoised on state changes) ────────────────────────────
|
||||
|
||||
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
|
||||
|
|
@ -378,7 +418,28 @@ export function Planner() {
|
|||
</Section>
|
||||
|
||||
{/* 3 — Staff rota */}
|
||||
<Section title="Staff Rota" action={<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>}>
|
||||
<Section
|
||||
title="Staff Rota"
|
||||
action={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={syncRota}
|
||||
disabled={syncing}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.3rem',
|
||||
background: 'var(--card-bg)', color: 'var(--text-dark)',
|
||||
border: '1px solid var(--card-border)', borderRadius: '6px',
|
||||
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={11} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
|
||||
{syncing ? 'Syncing…' : 'Sync from Workforce'}
|
||||
</button>
|
||||
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>{wfSyncLabel()}</span>
|
||||
<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="table-scroll">
|
||||
<StaffTable
|
||||
bookings={bookings}
|
||||
|
|
@ -386,6 +447,8 @@ export function Planner() {
|
|||
required={required}
|
||||
tolerance={tolerance}
|
||||
onChange={handleStaffChange}
|
||||
workforceRota={workforceRota}
|
||||
wfStaff={wfStaff}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
|
@ -708,12 +771,14 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
|||
|
||||
// ── Staff Table ───────────────────────────────────────────────────────────────
|
||||
|
||||
function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
||||
function StaffTable({ bookings, staff, required, tolerance, onChange, workforceRota, wfStaff }: {
|
||||
bookings: BookingsData
|
||||
staff: StaffMember[]
|
||||
required: Record<string, RequiredDay>
|
||||
tolerance: number
|
||||
onChange: (next: StaffMember[]) => void
|
||||
workforceRota: WorkforceRota | null
|
||||
wfStaff: { id: string; name: string }[]
|
||||
}) {
|
||||
const { dates } = bookings
|
||||
const tolHrs = tolerance / 60
|
||||
|
|
@ -739,6 +804,8 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
|||
onChange(staff.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
const rotaMembers = workforceRota?.staff ?? []
|
||||
|
||||
return (
|
||||
<table className="hk-table">
|
||||
<thead>
|
||||
|
|
@ -749,6 +816,39 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Workforce rota rows — read-only */}
|
||||
{rotaMembers.map(member => (
|
||||
<tr key={'wf-' + member.id} style={{ background: 'rgba(42,100,72,0.05)' }}>
|
||||
<td className="col-label">
|
||||
<span style={{
|
||||
display: 'inline-block', fontSize: '0.67rem', fontWeight: 700,
|
||||
background: 'rgba(42,100,72,0.18)', color: '#1a7a4a',
|
||||
borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5',
|
||||
}}>WF</span>
|
||||
{member.name}
|
||||
</td>
|
||||
{dates.map(date => {
|
||||
const shift = member.days[date]
|
||||
return (
|
||||
<td key={date} style={{ textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }}>
|
||||
{shift ? (
|
||||
<>
|
||||
<div style={{ fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }}>{shift.times}</div>
|
||||
<div style={{ fontWeight: 600 }}>{fmtH(shift.hours)}</div>
|
||||
</>
|
||||
) : <span style={{ color: 'var(--text-mid)' }}>—</span>}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td />
|
||||
</tr>
|
||||
))}
|
||||
{/* Manual rows */}
|
||||
{wfStaff.length > 0 && (
|
||||
<datalist id="wf-staff-datalist">
|
||||
{wfStaff.map(s => <option key={s.id} value={s.name} />)}
|
||||
</datalist>
|
||||
)}
|
||||
{staff.map((member, i) => (
|
||||
<tr key={i}>
|
||||
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
|
||||
|
|
@ -756,6 +856,7 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
|||
className="hk-text-input"
|
||||
value={member.name}
|
||||
placeholder="Staff name"
|
||||
list={wfStaff.length > 0 ? 'wf-staff-datalist' : undefined}
|
||||
onChange={e => setMemberName(i, e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
|
|
@ -783,25 +884,28 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
|||
<tr>
|
||||
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
|
||||
{dates.map(date => {
|
||||
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
||||
return <td key={date}>{fmtH(avail)}</td>
|
||||
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)
|
||||
return <td key={date}>{fmtH(rotaHrs + manualHrs)}</td>
|
||||
})}
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
|
||||
{dates.map(date => {
|
||||
const avail = staff.reduce((s, m) => s + (m.hours[date] || 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 reqHrs = required[date].booked + required[date].general
|
||||
return <td key={date}><DiffCell available={avail} required={reqHrs} tolerance={tolHrs} /></td>
|
||||
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={reqHrs} tolerance={tolHrs} /></td>
|
||||
})}
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
|
||||
{dates.map(date => {
|
||||
const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
||||
return <td key={date}><DiffCell available={avail} required={required[date].total} tolerance={tolHrs} /></td>
|
||||
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)
|
||||
return <td key={date}><DiffCell available={rotaHrs + manualHrs} required={required[date].total} tolerance={tolHrs} /></td>
|
||||
})}
|
||||
<td />
|
||||
</tr>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue