hk-planner/backend/src/lib/syncJobs.js
jtricerolph 9bfd14a082 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>
2026-07-22 18:25:02 +00:00

94 lines
3.4 KiB
JavaScript

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 }
}