Add timesheet sync: pull actual hours, daily cron, per-cell source indicators

- fetchTimesheetShifts: hits /api/v2/shifts, subtracts breaks, carries PENDING/APPROVED status
- syncJobs.js: shared runRotaSync/runTimesheetSync logic used by routes and cron
- Rota sync now skips dates already marked source='timesheet'
- POST /api/workforce/sync-timesheets: on-demand pull of last 7 days actual hours
- Daily 6am cron: rota sync then timesheet sync, each logged independently
- upsertWorkforceShifts now writes source per row (workforce or timesheet)
- Frontend: Pull timesheets button alongside Sync rota button
- Clock icon on timesheet cells — amber for PENDING, green for APPROVED
- Both buttons disabled while either sync is in progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-22 18:25:02 +00:00
parent 109a4ea194
commit 9bfd14a082
16 changed files with 1129 additions and 212 deletions

View file

@ -54,9 +54,9 @@ export async function upsertWorkforceShifts(dailyRows) {
for (const row of dailyRows) {
await client.query(
`INSERT INTO workforce_daily_shifts (date, synced_at, source, staff)
VALUES ($1, now(), 'workforce', $2::jsonb)
ON CONFLICT (date) DO UPDATE SET synced_at = now(), staff = EXCLUDED.staff`,
[row.date, JSON.stringify(row.staff)]
VALUES ($1, now(), $2, $3::jsonb)
ON CONFLICT (date) DO UPDATE SET synced_at = now(), source = EXCLUDED.source, staff = EXCLUDED.staff`,
[row.date, row.source || 'workforce', JSON.stringify(row.staff)]
)
}
await client.query('COMMIT')

View file

@ -1,10 +1,12 @@
import Fastify from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import cron from 'node-cron'
import { initDb } from './db.js'
import { bookingRoutes } from './routes/bookings.js'
import { configRoutes } from './routes/config.js'
import { workforceRoutes } from './routes/workforce.js'
import { runRotaSync, runTimesheetSync } from './lib/syncJobs.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
@ -25,3 +27,20 @@ try {
app.log.error(err)
process.exit(1)
}
// Daily 6am: rota sync (today-7 → today+28) then timesheet sync (last 7 days)
cron.schedule('0 6 * * *', async () => {
app.log.info('cron: starting daily workforce sync')
try {
const r = await runRotaSync()
app.log.info({ dates_synced: r.dates_synced }, 'cron: rota sync complete')
} catch (err) {
app.log.error({ err: err.message }, 'cron: rota sync failed')
}
try {
const r = await runTimesheetSync()
app.log.info({ dates_synced: r.dates_synced }, 'cron: timesheet sync complete')
} catch (err) {
app.log.error({ err: err.message }, 'cron: timesheet sync failed')
}
})

View file

@ -0,0 +1,94 @@
import { getConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
import { fetchShifts, fetchTimesheetShifts } from './workforce.js'
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export async function runRotaSync() {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
const from = fmtDate(fromDate)
const to = fmtDate(toDate)
// API limit: max 7 days per request — split into weekly chunks
const chunks = []
const cur = new Date(fromDate)
while (cur <= toDate) {
const chunkFrom = fmtDate(cur)
const chunkToDate = new Date(cur)
chunkToDate.setDate(chunkToDate.getDate() + 6)
if (chunkToDate > toDate) chunkToDate.setTime(toDate.getTime())
chunks.push({ from: chunkFrom, to: fmtDate(chunkToDate) })
cur.setDate(cur.getDate() + 7)
}
const staffMap = {}
for (const chunk of chunks) {
const chunkStaff = await fetchShifts(chunk.from, chunk.to, deptIds)
for (const member of chunkStaff) {
if (!staffMap[member.id]) staffMap[member.id] = { id: member.id, name: member.name, days: {} }
Object.assign(staffMap[member.id].days, member.days)
}
}
const byDate = {}
for (const member of Object.values(staffMap)) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
}
}
// Skip dates already replaced with timesheet actuals
const existing = await getWorkforceShifts(from, to)
const timesheetDates = new Set(
Object.entries(existing).filter(([, d]) => d.source === 'timesheet').map(([date]) => date)
)
const dailyRows = []
const day = new Date(fromDate)
while (day <= toDate) {
const d = fmtDate(day)
if (!timesheetDates.has(d)) dailyRows.push({ date: d, staff: byDate[d] || [], source: 'workforce' })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
}
export async function runTimesheetSync(days = 7) {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) throw new Error('No HK departments selected — configure in Category Settings')
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - days)
const from = fmtDate(fromDate)
const to = fmtDate(today)
const staffList = await fetchTimesheetShifts(from, to, deptIds)
const byDate = {}
for (const member of staffList) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times, status: shift.status })
}
}
const dailyRows = []
const day = new Date(fromDate)
while (day <= today) {
const d = fmtDate(day)
dailyRows.push({ date: d, staff: byDate[d] || [], source: 'timesheet' })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
}

View file

@ -72,6 +72,54 @@ export async function fetchStaff(deptIds) {
.map(u => ({ id: String(u.id), name: u.name }))
}
export async function fetchTimesheetShifts(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/shifts?from=${from}&to=${to}`
if (locationId) path += `&report_location_id=${locationId}`
const shifts = await wfFetchPaged(path)
const filtered = shifts.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 = s.date // already 'YYYY-MM-DD'
const hrs = (s.finish && s.start)
? Math.max(0, (s.finish - s.start) / 3600 - (s.break_length || 0) / 60)
: 0
if (!byUser[uid]) byUser[uid] = {}
if (!byUser[uid][date]) byUser[uid][date] = { hours: 0, shifts: [], status: s.status }
byUser[uid][date].hours += hrs
byUser[uid][date].shifts.push({ start: s.start, finish: s.finish })
if (s.status === 'APPROVED') byUser[uid][date].status = 'APPROVED'
}
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 = firstStart && lastFinish
? sorted.length > 1
? `${fmtTime(firstStart)}${fmtTime(lastFinish)} (${sorted.length} shifts)`
: `${fmtTime(firstStart)}${fmtTime(lastFinish)}`
: '?'
return [date, { hours: parseFloat(data.hours.toFixed(2)), times, status: data.status }]
})
),
}))
}
export async function fetchShifts(from, to, deptIds) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id

View file

@ -1,9 +1,10 @@
import { requireAuth, requireCap } from '../auth.js'
import { getConfig, setConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
import { getConfig, setConfig, getWorkforceShifts } from '../db.js'
import { fetchDepartments, fetchStaff } from '../lib/workforce.js'
import { runRotaSync, runTimesheetSync } from '../lib/syncJobs.js'
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
function errStatus(msg) {
return msg.includes('not configured') ? 503 : 502
}
export async function workforceRoutes(app) {
@ -15,72 +16,29 @@ export async function workforceRoutes(app) {
try {
return await fetchDepartments()
} catch (err) {
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
return reply.status(errStatus(err.message)).send({ error: err.message })
}
})
// ── POST /api/workforce/sync — rolling window: today-7 → today+28 ───────────
// ── POST /api/workforce/sync — rolling rota window: today-7 → today+28 ──────
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
const deptIds = (await getConfig('workforce_departments', [])) || []
if (!deptIds.length) {
return reply.status(400).send({ error: 'No HK departments selected — configure in Category Settings' })
}
const today = new Date()
const fromDate = new Date(today); fromDate.setDate(fromDate.getDate() - 7)
const toDate = new Date(today); toDate.setDate(toDate.getDate() + 28)
const from = fmtDate(fromDate)
const to = fmtDate(toDate)
try {
// API limit: max 7 days per request — split window into weekly chunks
const chunks = []
const cur = new Date(fromDate)
while (cur <= toDate) {
const chunkFrom = fmtDate(cur)
const chunkToDate = new Date(cur)
chunkToDate.setDate(chunkToDate.getDate() + 6)
if (chunkToDate > toDate) chunkToDate.setTime(toDate.getTime())
chunks.push({ from: chunkFrom, to: fmtDate(chunkToDate) })
cur.setDate(cur.getDate() + 7)
}
const staffMap = {}
for (const chunk of chunks) {
const chunkStaff = await fetchShifts(chunk.from, chunk.to, deptIds)
for (const member of chunkStaff) {
if (!staffMap[member.id]) staffMap[member.id] = { id: member.id, name: member.name, days: {} }
Object.assign(staffMap[member.id].days, member.days)
}
}
const staffList = Object.values(staffMap)
// Pivot per-member → per-date
const byDate = {}
for (const member of staffList) {
for (const [date, shift] of Object.entries(member.days)) {
if (!byDate[date]) byDate[date] = []
byDate[date].push({ id: member.id, name: member.name, hours: shift.hours, times: shift.times })
}
}
// Build a row for every date in the window (empty staff = no one scheduled)
const dailyRows = []
const day = new Date(fromDate)
while (day <= toDate) {
const d = fmtDate(day)
dailyRows.push({ date: d, staff: byDate[d] || [] })
day.setDate(day.getDate() + 1)
}
await upsertWorkforceShifts(dailyRows)
return { ok: true, from, to, dates_synced: dailyRows.length }
return await runRotaSync()
} catch (err) {
req.log.error({ err: err.message }, 'workforce sync failed')
const status = err.message.includes('not configured') ? 503 : 502
return reply.status(status).send({ error: err.message })
req.log.error({ err: err.message }, 'rota sync failed')
return reply.status(errStatus(err.message)).send({ error: err.message })
}
})
// ── POST /api/workforce/sync-timesheets — last 7 days of actual hours ────────
app.post('/api/workforce/sync-timesheets', { preHandler: requireCap('planner') }, async (req, reply) => {
try {
return await runTimesheetSync()
} catch (err) {
req.log.error({ err: err.message }, 'timesheet sync failed')
return reply.status(errStatus(err.message)).send({ error: err.message })
}
})
@ -108,8 +66,7 @@ export async function workforceRoutes(app) {
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 })
return reply.status(errStatus(err.message)).send({ error: err.message })
}
})
}