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

@ -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 }]
})
),
}))
}