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:
parent
109a4ea194
commit
9bfd14a082
16 changed files with 1129 additions and 212 deletions
94
backend/src/lib/syncJobs.js
Normal file
94
backend/src/lib/syncJobs.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue