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
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 }]
|
||||
})
|
||||
),
|
||||
}))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue