diff --git a/backend/src/index.js b/backend/src/index.js index 1645b5d..8725d40 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,6 +4,7 @@ import cors from '@fastify/cors' import { initDb } from './db.js' import { bookingRoutes } from './routes/bookings.js' import { configRoutes } from './routes/config.js' +import { workforceRoutes } from './routes/workforce.js' const app = Fastify({ logger: true, trustProxy: true }) @@ -14,6 +15,7 @@ app.get('/health', async () => ({ status: 'healthy' })) await app.register(bookingRoutes) await app.register(configRoutes) +await app.register(workforceRoutes) try { await initDb() diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js new file mode 100644 index 0000000..fb08e55 --- /dev/null +++ b/backend/src/lib/workforce.js @@ -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 }] + }) + ), + })) +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js index e2672f5..939189c 100644 --- a/backend/src/routes/config.js +++ b/backend/src/routes/config.js @@ -8,7 +8,7 @@ export async function configRoutes(app) { // ── GET /api/config — all settings at once ────────────────────────────── 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([ getConfig('time_requirements', {}), getConfig('staff_data', []), @@ -16,6 +16,8 @@ export async function configRoutes(app) { getConfig('general_tasks', []), getConfig('last_reviewed', null), getConfig('tolerance_minutes', 30), + getConfig('workforce_rota', null), + getConfig('workforce_departments', []), ]) const today = new Date() @@ -25,12 +27,14 @@ export async function configRoutes(app) { const yestStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}` return { - time_requirements: timeReqs || {}, - staff_data: staffData || [], - pickup_data: pickupData || {}, - general_tasks: generalTasks || [], - last_reviewed: lastReviewed || yestStr, - tolerance_minutes: toleranceMins != null ? toleranceMins : 30, + time_requirements: timeReqs || {}, + staff_data: staffData || [], + pickup_data: pickupData || {}, + general_tasks: generalTasks || [], + last_reviewed: lastReviewed || yestStr, + 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 } }) + // ── 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 ────────────────────────── app.get('/api/categories', { preHandler: requireCap('settings') }, async (req, reply) => { diff --git a/backend/src/routes/workforce.js b/backend/src/routes/workforce.js new file mode 100644 index 0000000..67841bd --- /dev/null +++ b/backend/src/routes/workforce.js @@ -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 }) + } + }) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c078b32..15c764d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + return request(`/workforce/sync?start=${start}&end=${end}`, { method: 'POST' }) +} + +export function getWorkforceStaff(): Promise<{ id: string; name: string }[]> { + return request('/workforce/staff') +} diff --git a/frontend/src/pages/CategorySettings.tsx b/frontend/src/pages/CategorySettings.tsx index 8a3efd1..37f9f77 100644 --- a/frontend/src/pages/CategorySettings.tsx +++ b/frontend/src/pages/CategorySettings.tsx @@ -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([]) - 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(null) + const [cats, setCats] = useState([]) + 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(null) + + // Workforce department state + const [wfDepts, setWfDepts] = useState<{ id: string; name: string }[]>([]) + const [wfSelected, setWfSelected] = useState([]) + 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
Loading categories…
} @@ -133,7 +178,7 @@ export function CategorySettings() { ))} -
+
+ + {/* ── Workforce Departments ─────────────────────────────────────────────── */} +

+ Workforce Departments +

+

+ Select the department(s) whose shifts should appear in the HK staff rota. +

+ + {wfNotConfigured && ( +
+ Workforce integration not configured — add the bearer token in Settings → Integrations → Workforce. +
+ )} + + {!wfNotConfigured && wfLoading && ( +
Loading departments…
+ )} + + {!wfNotConfigured && !wfLoading && ( + <> + {wfError && ( +
+ {wfError} +
+ )} + {wfMsg && ( +
+ {wfMsg} +
+ )} + + {wfDepts.length === 0 ? ( +
+ No departments found for this location. +
+ ) : ( +
+ {wfDepts.map(dept => ( + + ))} +
+ )} + + {wfSelected.length === 0 && wfDepts.length > 0 && ( +
+ Select at least one department to enable Workforce sync. +
+ )} + + + + )}
) } diff --git a/frontend/src/pages/Planner.tsx b/frontend/src/pages/Planner.tsx index 3d4d469..a581416 100644 --- a/frontend/src/pages/Planner.tsx +++ b/frontend/src/pages/Planner.tsx @@ -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({}) const [generalTasks, setGeneralTasks] = useState([]) const [tolerance, setTolerance] = useState(30) + const [workforceRota, setWorkforceRota] = useState(null) + const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([]) + const [syncing, setSyncing] = useState(false) const [weekStart, setWeekStart] = useState(null) const [lastViewed, setLastViewed] = useState('') const [savedLastReviewed, setSavedLastReviewed] = useState('') @@ -136,6 +139,7 @@ export function Planner() { const timers = useRef>>({}) const saveMsgTimer = useRef | 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() { {/* 3 — Staff rota */} -
+ Add staff}> +
+ + {wfSyncLabel()} + + Add staff + + } + >
@@ -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 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 ( @@ -749,6 +816,39 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: { + {/* Workforce rota rows — read-only */} + {rotaMembers.map(member => ( + + + {dates.map(date => { + const shift = member.days[date] + return ( + + ) + })} + + ))} + {/* Manual rows */} + {wfStaff.length > 0 && ( + + {wfStaff.map(s => + )} {staff.map((member, i) => ( @@ -783,25 +884,28 @@ function StaffTable({ bookings, staff, required, tolerance, onChange }: { {dates.map(date => { - const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) - return + 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 })} {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 + return })} {dates.map(date => { - const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) - return + 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 })} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index f202624..b50575c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -49,6 +49,23 @@ export interface GeneralTask { export type PickupData = Record> +export interface WorkforceShiftDay { + hours: number + times: string +} + +export interface WorkforceRotaMember { + id: string + name: string + days: Record +} + +export interface WorkforceRota { + last_sync: string + dates: [string, string] + staff: WorkforceRotaMember[] +} + export interface RequiredDay { booked: number pickup: number
+ WF + {member.name} + + {shift ? ( + <> +
{shift.times}
+
{fmtH(shift.hours)}
+ + ) : } +
+
@@ -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)} />
Total Available{fmtH(avail)}{fmtH(rotaHrs + manualHrs)}
vs Booked
vs with Pickup