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
|
|
@ -4,6 +4,7 @@ import cors from '@fastify/cors'
|
||||||
import { initDb } from './db.js'
|
import { initDb } from './db.js'
|
||||||
import { bookingRoutes } from './routes/bookings.js'
|
import { bookingRoutes } from './routes/bookings.js'
|
||||||
import { configRoutes } from './routes/config.js'
|
import { configRoutes } from './routes/config.js'
|
||||||
|
import { workforceRoutes } from './routes/workforce.js'
|
||||||
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ app.get('/health', async () => ({ status: 'healthy' }))
|
||||||
|
|
||||||
await app.register(bookingRoutes)
|
await app.register(bookingRoutes)
|
||||||
await app.register(configRoutes)
|
await app.register(configRoutes)
|
||||||
|
await app.register(workforceRoutes)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initDb()
|
await initDb()
|
||||||
|
|
|
||||||
124
backend/src/lib/workforce.js
Normal file
124
backend/src/lib/workforce.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.106:3080'
|
||||||
|
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
|
||||||
|
|
||||||
|
let _credsCache = null // { creds, expires_at }
|
||||||
|
|
||||||
|
async function getWorkforceCreds() {
|
||||||
|
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
|
||||||
|
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
|
||||||
|
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Workforce integration not configured — add bearer token in Settings')
|
||||||
|
const creds = await res.json()
|
||||||
|
if (!creds.bearer_token) throw new Error('Workforce integration not configured — add bearer token in Settings')
|
||||||
|
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
|
||||||
|
return creds
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wfFetch(path) {
|
||||||
|
const creds = await getWorkforceCreds()
|
||||||
|
const base = creds.base_url || 'https://my.workforce.com'
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${creds.bearer_token}` },
|
||||||
|
signal: AbortSignal.timeout(12000),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '')
|
||||||
|
throw new Error(`Workforce API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wfFetchPaged(path) {
|
||||||
|
const results = []
|
||||||
|
let page = 1
|
||||||
|
while (true) {
|
||||||
|
const sep = path.includes('?') ? '&' : '?'
|
||||||
|
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
|
||||||
|
const items = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (data.users ?? data.departments ?? data.schedules ?? data.teams ?? [])
|
||||||
|
results.push(...items)
|
||||||
|
if (items.length < 100) break
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(unixSecs) {
|
||||||
|
return new Date(unixSecs * 1000).toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit', minute: '2-digit', timeZone: 'Europe/London',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDepartments() {
|
||||||
|
const creds = await getWorkforceCreds()
|
||||||
|
const locationId = creds.location_id ? String(creds.location_id) : null
|
||||||
|
const all = await wfFetchPaged('/api/v2/departments')
|
||||||
|
const filtered = locationId ? all.filter(d => String(d.location_id) === locationId) : all
|
||||||
|
return filtered.map(d => ({ id: String(d.id), name: d.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaff(deptIds) {
|
||||||
|
const creds = await getWorkforceCreds()
|
||||||
|
const locationId = creds.location_id
|
||||||
|
const path = locationId ? `/api/v2/users?location_id=${locationId}` : '/api/v2/users'
|
||||||
|
const all = await wfFetchPaged(path)
|
||||||
|
const active = all.filter(u => u.active !== false)
|
||||||
|
if (!deptIds || !deptIds.length) return active.map(u => ({ id: String(u.id), name: u.name }))
|
||||||
|
return active
|
||||||
|
.filter(u => (u.department_ids ?? []).some(id => deptIds.includes(String(id))))
|
||||||
|
.map(u => ({ id: String(u.id), name: u.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchShifts(from, to, deptIds) {
|
||||||
|
const creds = await getWorkforceCreds()
|
||||||
|
const locationId = creds.location_id
|
||||||
|
|
||||||
|
const staffList = await fetchStaff(deptIds)
|
||||||
|
const nameMap = Object.fromEntries(staffList.map(s => [s.id, s.name]))
|
||||||
|
|
||||||
|
let path = `/api/v2/schedules?from=${from}&to=${to}`
|
||||||
|
if (locationId) path += `&location_id=${locationId}`
|
||||||
|
const schedules = await wfFetchPaged(path)
|
||||||
|
|
||||||
|
const filtered = schedules.filter(s => deptIds.includes(String(s.department_id)))
|
||||||
|
|
||||||
|
const byUser = {}
|
||||||
|
for (const s of filtered) {
|
||||||
|
const uid = String(s.user_id)
|
||||||
|
if (!nameMap[uid]) continue
|
||||||
|
|
||||||
|
const date = new Date(s.start * 1000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
let breakHrs = 0
|
||||||
|
if (Array.isArray(s.breaks) && s.breaks.length) {
|
||||||
|
breakHrs = s.breaks.reduce((sum, b) => sum + (b.finish - b.start) / 3600, 0)
|
||||||
|
} else {
|
||||||
|
breakHrs = (s.automatic_break_length ?? 0) / 60
|
||||||
|
}
|
||||||
|
const shiftHrs = Math.max(0, (s.finish - s.start) / 3600 - breakHrs)
|
||||||
|
|
||||||
|
if (!byUser[uid]) byUser[uid] = {}
|
||||||
|
if (!byUser[uid][date]) byUser[uid][date] = { hours: 0, shifts: [] }
|
||||||
|
byUser[uid][date].hours += shiftHrs
|
||||||
|
byUser[uid][date].shifts.push({ start: s.start, finish: s.finish })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(byUser).map(([uid, days]) => ({
|
||||||
|
id: uid,
|
||||||
|
name: nameMap[uid],
|
||||||
|
days: Object.fromEntries(
|
||||||
|
Object.entries(days).map(([date, data]) => {
|
||||||
|
const sorted = data.shifts.sort((a, b) => a.start - b.start)
|
||||||
|
const firstStart = sorted[0].start
|
||||||
|
const lastFinish = sorted[sorted.length - 1].finish
|
||||||
|
const times = sorted.length > 1
|
||||||
|
? `${fmtTime(firstStart)}–${fmtTime(lastFinish)} (${sorted.length} shifts)`
|
||||||
|
: `${fmtTime(firstStart)}–${fmtTime(lastFinish)}`
|
||||||
|
return [date, { hours: parseFloat(data.hours.toFixed(2)), times }]
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ export async function configRoutes(app) {
|
||||||
// ── GET /api/config — all settings at once ──────────────────────────────
|
// ── GET /api/config — all settings at once ──────────────────────────────
|
||||||
|
|
||||||
app.get('/api/config', async (req) => {
|
app.get('/api/config', async (req) => {
|
||||||
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins] =
|
const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins, workforceRota, workforceDepts] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getConfig('time_requirements', {}),
|
getConfig('time_requirements', {}),
|
||||||
getConfig('staff_data', []),
|
getConfig('staff_data', []),
|
||||||
|
|
@ -16,6 +16,8 @@ export async function configRoutes(app) {
|
||||||
getConfig('general_tasks', []),
|
getConfig('general_tasks', []),
|
||||||
getConfig('last_reviewed', null),
|
getConfig('last_reviewed', null),
|
||||||
getConfig('tolerance_minutes', 30),
|
getConfig('tolerance_minutes', 30),
|
||||||
|
getConfig('workforce_rota', null),
|
||||||
|
getConfig('workforce_departments', []),
|
||||||
])
|
])
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
|
@ -31,6 +33,8 @@ export async function configRoutes(app) {
|
||||||
general_tasks: generalTasks || [],
|
general_tasks: generalTasks || [],
|
||||||
last_reviewed: lastReviewed || yestStr,
|
last_reviewed: lastReviewed || yestStr,
|
||||||
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
tolerance_minutes: toleranceMins != null ? toleranceMins : 30,
|
||||||
|
workforce_rota: workforceRota || null,
|
||||||
|
workforce_departments: workforceDepts || [],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -121,6 +125,19 @@ export async function configRoutes(app) {
|
||||||
return { ok: true, date }
|
return { ok: true, date }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── PUT /api/config/workforce-departments ────────────────────────────────
|
||||||
|
|
||||||
|
app.put('/api/config/workforce-departments', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
const { dept_ids } = req.body || {}
|
||||||
|
if (!Array.isArray(dept_ids)) return reply.status(400).send({ error: 'dept_ids must be an array' })
|
||||||
|
const clean = dept_ids.filter(id => typeof id === 'string')
|
||||||
|
await Promise.all([
|
||||||
|
setConfig('workforce_departments', clean),
|
||||||
|
setConfig('workforce_staff_cache', null),
|
||||||
|
])
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /api/categories — settings cap required ──────────────────────────
|
// ── GET /api/categories — settings cap required ──────────────────────────
|
||||||
|
|
||||||
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
|
|
||||||
61
backend/src/routes/workforce.js
Normal file
61
backend/src/routes/workforce.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { requireAuth, requireCap } from '../auth.js'
|
||||||
|
import { getConfig, setConfig } from '../db.js'
|
||||||
|
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
|
||||||
|
|
||||||
|
export async function workforceRoutes(app) {
|
||||||
|
app.addHook('preHandler', requireAuth)
|
||||||
|
|
||||||
|
// ── GET /api/workforce/departments — list depts for this location ────────────
|
||||||
|
|
||||||
|
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||||
|
try {
|
||||||
|
return await fetchDepartments()
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message.includes('not configured') ? 503 : 502
|
||||||
|
return reply.status(status).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── POST /api/workforce/sync?start=&end= ─────────────────────────────────────
|
||||||
|
|
||||||
|
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', [])) || []
|
||||||
|
if (!deptIds.length) {
|
||||||
|
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staff = await fetchShifts(start, end, deptIds)
|
||||||
|
const snapshot = { last_sync: new Date().toISOString(), dates: [start, end], staff }
|
||||||
|
await setConfig('workforce_rota', snapshot)
|
||||||
|
return snapshot
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message.includes('not configured') ? 503 : 502
|
||||||
|
return reply.status(status).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── GET /api/workforce/staff — staff list for manual row datalist ─────────────
|
||||||
|
|
||||||
|
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||||
|
const deptIds = (await getConfig('workforce_departments', [])) || []
|
||||||
|
if (!deptIds.length) return []
|
||||||
|
|
||||||
|
const cached = await getConfig('workforce_staff_cache', null)
|
||||||
|
if (cached && Date.now() - new Date(cached.fetched_at).getTime() < 3_600_000) {
|
||||||
|
return cached.staff
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const staff = await fetchStaff(deptIds)
|
||||||
|
await setConfig('workforce_staff_cache', { fetched_at: new Date().toISOString(), staff })
|
||||||
|
return staff
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message.includes('not configured') ? 503 : 502
|
||||||
|
return reply.status(status).send({ error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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'
|
const BASE = '/hk-planner/api'
|
||||||
|
|
||||||
|
|
@ -9,6 +9,8 @@ export interface ConfigData {
|
||||||
general_tasks: GeneralTask[]
|
general_tasks: GeneralTask[]
|
||||||
last_reviewed: string
|
last_reviewed: string
|
||||||
tolerance_minutes: number
|
tolerance_minutes: number
|
||||||
|
workforce_rota: WorkforceRota | null
|
||||||
|
workforce_departments: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryConfig {
|
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 }> {
|
export function testNewbook(): Promise<{ ok: boolean; message?: string; error?: string }> {
|
||||||
return request('/newbook/test', { method: 'POST' })
|
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')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { GripVertical } from 'lucide-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'
|
import type { CategoryConfig } from '../api'
|
||||||
|
|
||||||
export function CategorySettings() {
|
export function CategorySettings() {
|
||||||
|
|
@ -12,10 +12,36 @@ export function CategorySettings() {
|
||||||
const [msg, setMsg] = useState('')
|
const [msg, setMsg] = useState('')
|
||||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
getCategories()
|
getCategories()
|
||||||
.then(r => { setCats(r.categories); setLoading(false) })
|
.then(r => { setCats(r.categories); setLoading(false) })
|
||||||
.catch(e => { setError(e.message); 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) {
|
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) {
|
if (loading) {
|
||||||
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories…</div>
|
return <div style={{ padding: '2rem', color: 'var(--text-mid)' }}>Loading categories…</div>
|
||||||
}
|
}
|
||||||
|
|
@ -133,7 +178,7 @@ export function CategorySettings() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '2.5rem' }}>
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
|
@ -157,6 +202,85 @@ export function CategorySettings() {
|
||||||
{testing ? 'Testing…' : 'Test Newbook Connection'}
|
{testing ? 'Testing…' : 'Test Newbook Connection'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 <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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, putTimeReq, putStaff, putPickup,
|
getBookings, getConfig, putTimeReq, putStaff, putPickup,
|
||||||
putGeneralTasks, putLastReviewed,
|
putGeneralTasks, putLastReviewed, syncWorkforceRota, getWorkforceStaff,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import type {
|
import type {
|
||||||
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
BookingsData, TimeReqs, StaffMember, GeneralTask,
|
||||||
PickupData, RequiredDay, DayData,
|
PickupData, RequiredDay, DayData, WorkforceRota,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -127,6 +127,9 @@ export function Planner() {
|
||||||
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 [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 [weekStart, setWeekStart] = useState<string | null>(null)
|
||||||
const [lastViewed, setLastViewed] = useState('')
|
const [lastViewed, setLastViewed] = useState('')
|
||||||
const [savedLastReviewed, setSavedLastReviewed] = useState('')
|
const [savedLastReviewed, setSavedLastReviewed] = useState('')
|
||||||
|
|
@ -136,6 +139,7 @@ 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)
|
||||||
|
|
||||||
function debounce(key: string, fn: () => void, delay = 400) {
|
function debounce(key: string, fn: () => void, delay = 400) {
|
||||||
clearTimeout(timers.current[key])
|
clearTimeout(timers.current[key])
|
||||||
|
|
@ -169,6 +173,7 @@ export function Planner() {
|
||||||
setPickup(cfg.pickup_data || {})
|
setPickup(cfg.pickup_data || {})
|
||||||
setGeneralTasks(cfg.general_tasks || [])
|
setGeneralTasks(cfg.general_tasks || [])
|
||||||
setTolerance(cfg.tolerance_minutes ?? 30)
|
setTolerance(cfg.tolerance_minutes ?? 30)
|
||||||
|
setWorkforceRota(cfg.workforce_rota || null)
|
||||||
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)
|
||||||
|
|
@ -182,6 +187,14 @@ 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
|
||||||
|
useEffect(() => {
|
||||||
|
if (workforceRota && !wfStaffLoaded.current) {
|
||||||
|
wfStaffLoaded.current = true
|
||||||
|
getWorkforceStaff().then(setWfStaff).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [workforceRota])
|
||||||
|
|
||||||
// Beacon save on unload
|
// Beacon save on unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onUnload() {
|
function onUnload() {
|
||||||
|
|
@ -274,6 +287,33 @@ export function Planner() {
|
||||||
handleTasksChange([...generalTasks, { name: '', hours: {} }])
|
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) ────────────────────────────
|
// ── Required hours (memoised on state changes) ────────────────────────────
|
||||||
|
|
||||||
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
|
const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null
|
||||||
|
|
@ -378,7 +418,28 @@ export function Planner() {
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 3 — Staff rota */}
|
{/* 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">
|
<div className="table-scroll">
|
||||||
<StaffTable
|
<StaffTable
|
||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
|
|
@ -386,6 +447,8 @@ export function Planner() {
|
||||||
required={required}
|
required={required}
|
||||||
tolerance={tolerance}
|
tolerance={tolerance}
|
||||||
onChange={handleStaffChange}
|
onChange={handleStaffChange}
|
||||||
|
workforceRota={workforceRota}
|
||||||
|
wfStaff={wfStaff}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -708,12 +771,14 @@ function RequiredTable({ bookings, required }: { bookings: BookingsData; require
|
||||||
|
|
||||||
// ── Staff Table ───────────────────────────────────────────────────────────────
|
// ── Staff Table ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
function StaffTable({ bookings, staff, required, tolerance, onChange, workforceRota, wfStaff }: {
|
||||||
bookings: BookingsData
|
bookings: BookingsData
|
||||||
staff: StaffMember[]
|
staff: StaffMember[]
|
||||||
required: Record<string, RequiredDay>
|
required: Record<string, RequiredDay>
|
||||||
tolerance: number
|
tolerance: number
|
||||||
onChange: (next: StaffMember[]) => void
|
onChange: (next: StaffMember[]) => void
|
||||||
|
workforceRota: WorkforceRota | null
|
||||||
|
wfStaff: { id: string; name: string }[]
|
||||||
}) {
|
}) {
|
||||||
const { dates } = bookings
|
const { dates } = bookings
|
||||||
const tolHrs = tolerance / 60
|
const tolHrs = tolerance / 60
|
||||||
|
|
@ -739,6 +804,8 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
||||||
onChange(staff.filter((_, idx) => idx !== i))
|
onChange(staff.filter((_, idx) => idx !== i))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rotaMembers = workforceRota?.staff ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<table className="hk-table">
|
<table className="hk-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -749,6 +816,39 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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) => (
|
{staff.map((member, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td className="col-label" style={{ padding: '0.3rem 0.5rem' }}>
|
<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"
|
className="hk-text-input"
|
||||||
value={member.name}
|
value={member.name}
|
||||||
placeholder="Staff name"
|
placeholder="Staff name"
|
||||||
|
list={wfStaff.length > 0 ? 'wf-staff-datalist' : undefined}
|
||||||
onChange={e => setMemberName(i, e.target.value)}
|
onChange={e => setMemberName(i, e.target.value)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -783,25 +884,28 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: {
|
||||||
<tr>
|
<tr>
|
||||||
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
|
<td className="col-label" style={{ fontSize: '0.78rem', color: 'var(--text-mid)' }}>Total Available</td>
|
||||||
{dates.map(date => {
|
{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)
|
||||||
return <td key={date}>{fmtH(avail)}</td>
|
const manualHrs = staff.reduce((s, m) => s + (m.hours[date] || 0), 0)
|
||||||
|
return <td key={date}>{fmtH(rotaHrs + manualHrs)}</td>
|
||||||
})}
|
})}
|
||||||
<td />
|
<td />
|
||||||
</tr>
|
</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.72rem', color: 'var(--text-mid)' }}>vs Booked</td>
|
||||||
{dates.map(date => {
|
{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
|
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 />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
|
<td className="col-label" style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>vs with Pickup</td>
|
||||||
{dates.map(date => {
|
{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)
|
||||||
return <td key={date}><DiffCell available={avail} required={required[date].total} tolerance={tolHrs} /></td>
|
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 />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,23 @@ export interface GeneralTask {
|
||||||
|
|
||||||
export type PickupData = Record<string, Record<string, { count: number; total: number }>>
|
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 {
|
export interface RequiredDay {
|
||||||
booked: number
|
booked: number
|
||||||
pickup: number
|
pickup: number
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue