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

@ -1,8 +1,8 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { RefreshCw } from 'lucide-react'
import { RefreshCw, Clock } from 'lucide-react'
import {
getBookings, getConfig, putStaff, putPickup,
putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff,
putLastReviewed, putAdjustments, syncWorkforce, syncTimesheets, getWorkforceShifts, getWorkforceStaff,
} from '../api'
import type {
BookingsData, TimeReqs, StaffMember, GeneralTask,
@ -135,7 +135,8 @@ export function Planner() {
const [warnUnderRed, setWarnUnderRed] = useState(2)
const [wfShifts, setWfShifts] = useState<Record<string, WfDayData>>({})
const [wfStaff, setWfStaff] = useState<{ id: string; name: string }[]>([])
const [syncing, setSyncing] = useState(false)
const [syncing, setSyncing] = useState(false)
const [timesheetSyncing, setTimesheetSyncing] = useState(false)
const [weekStart, setWeekStart] = useState<string | null>(null)
const [lastViewed, setLastViewed] = useState('')
const [savedLastReviewed, setSavedLastReviewed] = useState('')
@ -312,6 +313,22 @@ export function Planner() {
}
}
async function syncTimesheetData() {
setTimesheetSyncing(true)
try {
await syncTimesheets()
if (bookings) {
const shifts = await getWorkforceShifts(bookings.dates[0], bookings.dates[bookings.dates.length - 1])
setWfShifts(shifts)
}
flash('Timesheets pulled from Workforce')
} catch (e) {
flash(e instanceof Error ? e.message : 'Timesheet sync failed', true)
} finally {
setTimesheetSyncing(false)
}
}
function wfSyncLabel(): string {
if (!bookings) return ''
const viewDates = bookings.dates.filter(d => wfShifts[d])
@ -447,7 +464,7 @@ export function Planner() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
onClick={syncRota}
disabled={syncing}
disabled={syncing || timesheetSyncing}
style={{
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
@ -456,7 +473,20 @@ export function Planner() {
}}
>
<RefreshCw size={11} strokeWidth={1.75} style={{ animation: syncing ? 'spin 1s linear infinite' : 'none' }} />
{syncing ? 'Syncing…' : 'Sync from Workforce'}
{syncing ? 'Syncing…' : 'Sync rota'}
</button>
<button
onClick={syncTimesheetData}
disabled={syncing || timesheetSyncing}
style={{
display: 'flex', alignItems: 'center', gap: '0.3rem',
background: 'var(--card-bg)', color: 'var(--text-dark)',
border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.3rem 0.65rem', fontSize: '0.78rem', fontWeight: 600,
}}
>
<Clock size={11} strokeWidth={1.75} style={{ animation: timesheetSyncing ? 'spin 1s linear infinite' : 'none' }} />
{timesheetSyncing ? 'Pulling…' : 'Pull timesheets'}
</button>
<span style={{ fontSize: '0.72rem', color: 'var(--text-mid)' }}>{wfSyncLabel()}</span>
<CtrlBtn onClick={addStaffRow}>+ Add staff</CtrlBtn>
@ -804,7 +834,7 @@ function pivotWfShifts(wfShifts: Record<string, WfDayData>, dates: string[]): Wf
if (!day) continue
for (const s of day.staff) {
if (!members[s.id]) members[s.id] = { id: s.id, name: s.name, days: {} }
members[s.id].days[date] = { hours: s.hours, times: s.times }
members[s.id].days[date] = { hours: s.hours, times: s.times, status: s.status }
}
}
return Object.values(members).sort((a, b) => a.name.localeCompare(b.name))
@ -869,13 +899,20 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
{member.name}
</td>
{dates.map(date => {
const shift = member.days[date]
const shift = member.days[date]
const isTimesheet = wfShifts[date]?.source === 'timesheet'
const isPending = isTimesheet && shift?.status !== 'APPROVED'
return (
<td key={date} style={{ textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }}>
{shift ? (
<>
<div style={{ fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }}>{shift.times}</div>
<div style={{ fontWeight: 600 }}>{fmtH(shift.hours)}</div>
<div style={{ fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.2rem' }}>
{isTimesheet && (
<Clock size={10} strokeWidth={1.75} style={{ color: isPending ? '#d97706' : '#1a7a4a', flexShrink: 0 }} />
)}
{fmtH(shift.hours)}
</div>
</>
) : <span style={{ color: 'var(--text-mid)' }}></span>}
</td>