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:
jtricerolph 2026-07-07 20:47:09 +00:00
parent 490a9a558b
commit b803b01ae1
8 changed files with 498 additions and 27 deletions

View file

@ -1,4 +1,4 @@
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs } from './types'
import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs, WorkforceRota } from './types'
const BASE = '/hk-planner/api'
@ -9,6 +9,8 @@ export interface ConfigData {
general_tasks: GeneralTask[]
last_reviewed: string
tolerance_minutes: number
workforce_rota: WorkforceRota | null
workforce_departments: string[]
}
export interface CategoryConfig {
@ -94,3 +96,23 @@ export function putCategories(order: string[], excluded: string[]): Promise<{ ok
export function testNewbook(): Promise<{ ok: boolean; message?: string; error?: string }> {
return request('/newbook/test', { method: 'POST' })
}
export function getWorkforceDepartments(): Promise<{ id: string; name: string }[]> {
return request('/workforce/departments')
}
export function putWorkforceDepartments(dept_ids: string[]): Promise<{ ok: boolean }> {
return request('/config/workforce-departments', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dept_ids }),
})
}
export function syncWorkforceRota(start: string, end: string): Promise<WorkforceRota> {
return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' })
}
export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> {
return request('/workforce/staff')
}

View file

@ -1,21 +1,47 @@
import { useState, useEffect } from 'react'
import { GripVertical } from 'lucide-react'
import { getCategories, putCategories, testNewbook } from '../api'
import { getCategories, putCategories, testNewbook, getConfig, getWorkforceDepartments, putWorkforceDepartments } from '../api'
import type { CategoryConfig } from '../api'
export function CategorySettings() {
const [cats, setCats] = useState<CategoryConfig[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [dragIdx, setDragIdx] = useState<number | null>(null)
const [cats, setCats] = useState<CategoryConfig[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState('')
const [msg, setMsg] = useState('')
const [dragIdx, setDragIdx] = useState<number | null>(null)
// Workforce department state
const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([])
const [wfSelected, setWfSelected] = useState<string[]>([])
const [wfLoading, setWfLoading] = useState(true)
const [wfError, setWfError] = useState('')
const [wfNotConfigured, setWfNotConfigured] = useState(false)
const [wfSaving, setWfSaving] = useState(false)
const [wfMsg, setWfMsg] = useState('')
useEffect(() => {
getCategories()
.then(r => { setCats(r.categories); setLoading(false) })
.catch(e => { setError(e.message); setLoading(false) })
// Load selected dept IDs from config and dept list in parallel
Promise.all([
getConfig().then(c => c.workforce_departments),
getWorkforceDepartments().catch((e: Error) => {
if (e.message.includes('503') || e.message.toLowerCase().includes('not configured')) {
setWfNotConfigured(true)
} else {
setWfError(e.message)
}
return null
}),
]).then(([selected, depts]) => {
if (selected) setWfSelected(selected)
if (depts) setWfDepts(depts)
setWfLoading(false)
})
}, [])
function toggleExcluded(i: number) {
@ -70,6 +96,25 @@ export function CategorySettings() {
}
}
function toggleWfDept(id: string) {
setWfSelected(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])
}
async function saveWfDepts() {
setWfSaving(true)
setWfError('')
setWfMsg('')
try {
await putWorkforceDepartments(wfSelected)
setWfMsg('Departments saved')
setTimeout(() => setWfMsg(''), 2500)
} catch (e) {
setWfError(e instanceof Error ? e.message : 'Save failed')
} finally {
setWfSaving(false)
}
}
if (loading) {
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories</div>
}
@ -133,7 +178,7 @@ export function CategorySettings() {
))}
</div>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
<button
onClick={save}
disabled={saving}
@ -157,6 +202,85 @@ export function CategorySettings() {
{testing ? 'Testing…' : 'Test Newbook Connection'}
</button>
</div>
{/* ── Workforce Departments ─────────────────────────────────────────────── */}
<h2 style={{ fontSize: '1rem', fontWeight: 700, color: 'var(--text-dark)', marginBottom: '0.25rem' }}>
Workforce Departments
</h2>
<p style={{ fontSize: '0.82rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Select the department(s) whose shifts should appear in the HK staff rota.
</p>
{wfNotConfigured && (
<div style={{ padding: '0.75rem', borderRadius: '8px', background: '#f8fafc', border: '1px solid var(--card-border)', color: 'var(--text-mid)', fontSize: '0.85rem' }}>
Workforce integration not configured add the bearer token in&nbsp;<strong>Settings Integrations Workforce</strong>.
</div>
)}
{!wfNotConfigured && wfLoading && (
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem' }}>Loading departments</div>
)}
{!wfNotConfigured && !wfLoading && (
<>
{wfError && (
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem' }}>
{wfError}
</div>
)}
{wfMsg && (
<div style={{ marginBottom: '0.75rem', padding: '0.75rem', borderRadius: '8px', background: '#dcfce7', color: 'var(--success)', fontSize: '0.875rem' }}>
{wfMsg}
</div>
)}
{wfDepts.length === 0 ? (
<div style={{ color: 'var(--text-mid)', fontSize: '0.85rem', marginBottom: '1rem' }}>
No departments found for this location.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem', marginBottom: '1rem' }}>
{wfDepts.map(dept => (
<label
key={dept.id}
style={{
display: 'flex', alignItems: 'center', gap: '0.625rem',
padding: '0.5rem 0.75rem',
border: '1px solid var(--card-border)', borderRadius: '7px',
background: wfSelected.includes(dept.id) ? 'rgba(42,100,72,0.05)' : 'var(--card-bg)',
cursor: 'pointer', fontSize: '0.9rem', color: 'var(--text-dark)',
}}
>
<input
type="checkbox"
checked={wfSelected.includes(dept.id)}
onChange={() => toggleWfDept(dept.id)}
/>
{dept.name}
</label>
))}
</div>
)}
{wfSelected.length === 0 && wfDepts.length > 0 && (
<div style={{ marginBottom: '0.75rem', fontSize: '0.8rem', color: 'var(--warning, #b45309)' }}>
Select at least one department to enable Workforce sync.
</div>
)}
<button
onClick={saveWfDepts}
disabled={wfSaving}
style={{
background: 'var(--hk-green)', color: '#fff', border: 'none',
borderRadius: '6px', padding: '0.5rem 1.25rem', fontSize: '0.875rem',
fontWeight: 600,
}}
>
{wfSaving ? 'Saving…' : 'Save Departments'}
</button>
</>
)}
</div>
)
}

View file

@ -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>

View file

@ -49,6 +49,23 @@ export interface GeneralTask {
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
export interface WorkforceShiftDay {
hours: number
times: string
}
export interface WorkforceRotaMember {
id: string
name: string
days: Record<string, WorkforceShiftDay>
}
export interface WorkforceRota {
last_sync: string
dates: [string, string]
staff: WorkforceRotaMember[]
}
export interface RequiredDay {
booked: number
pickup: number