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
|
|
@ -86,6 +86,9 @@ export function putWorkforceDepartments(dept_ids) {
|
|||
export function syncWorkforce() {
|
||||
return request('/workforce/sync', { method: 'POST' });
|
||||
}
|
||||
export function syncTimesheets() {
|
||||
return request('/workforce/sync-timesheets', { method: 'POST' });
|
||||
}
|
||||
export function getWorkforceShifts(start, end) {
|
||||
return request(`/workforce/shifts?start=${start}&end=${end}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ export function syncWorkforce(): Promise<{ ok: boolean; from: string; to: string
|
|||
return request('/workforce/sync', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function syncTimesheets(): Promise<{ ok: boolean; from: string; to: string; dates_synced: number }> {
|
||||
return request('/workforce/sync-timesheets', { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getWorkforceShifts(start: string, end: string): Promise<Record<string, WfDayData>> {
|
||||
return request(`/workforce/shifts?start=${start}&end=${end}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, getWorkforceShifts, getWorkforceStaff, } from '../api';
|
||||
import { RefreshCw, Clock } from 'lucide-react';
|
||||
import { getBookings, getConfig, putStaff, putPickup, putLastReviewed, putAdjustments, syncWorkforce, syncTimesheets, getWorkforceShifts, getWorkforceStaff, } from '../api';
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
|
|
@ -104,6 +104,7 @@ export function Planner() {
|
|||
const [wfShifts, setWfShifts] = useState({});
|
||||
const [wfStaff, setWfStaff] = useState([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [timesheetSyncing, setTimesheetSyncing] = useState(false);
|
||||
const [weekStart, setWeekStart] = useState(null);
|
||||
const [lastViewed, setLastViewed] = useState('');
|
||||
const [savedLastReviewed, setSavedLastReviewed] = useState('');
|
||||
|
|
@ -265,6 +266,23 @@ export function Planner() {
|
|||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if (!bookings)
|
||||
return '';
|
||||
|
|
@ -305,12 +323,17 @@ export function Planner() {
|
|||
}, children: saveMsg.text })), error && (_jsxs("div", { style: {
|
||||
marginBottom: '1rem', padding: '0.75rem 1rem', borderRadius: '8px',
|
||||
background: '#fee2e2', color: 'var(--danger)', fontSize: '0.875rem',
|
||||
}, children: ["Error: ", error] })), loading && !bookings && (_jsx("div", { style: { color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }, children: "Loading bookings\u2026" })), bookings && required && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "7-Day Occupancy", children: _jsx("div", { className: "table-scroll", children: _jsx(SummaryTable, { bookings: bookings, pickup: pickup, onPickupChange: handlePickupChange }) }) }), _jsx(Section, { title: "Required Hours", children: _jsx("div", { className: "table-scroll", children: _jsx(RequiredTable, { bookings: bookings, required: required }) }) }), _jsx(Section, { title: "Adjustments", action: _jsx(CtrlBtn, { onClick: addAdjustmentRow, children: "+ Add adjustment" }), children: _jsx("div", { className: "table-scroll", children: _jsx(AdjustmentsTable, { bookings: bookings, adjustments: adjustments, onChange: handleAdjustmentsChange }) }) }), _jsx(Section, { title: "Staff Rota", action: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }, children: [_jsxs("button", { onClick: syncRota, disabled: syncing, style: {
|
||||
}, children: ["Error: ", error] })), loading && !bookings && (_jsx("div", { style: { color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }, children: "Loading bookings\u2026" })), bookings && required && (_jsxs(_Fragment, { children: [_jsx(Section, { title: "7-Day Occupancy", children: _jsx("div", { className: "table-scroll", children: _jsx(SummaryTable, { bookings: bookings, pickup: pickup, onPickupChange: handlePickupChange }) }) }), _jsx(Section, { title: "Required Hours", children: _jsx("div", { className: "table-scroll", children: _jsx(RequiredTable, { bookings: bookings, required: required }) }) }), _jsx(Section, { title: "Adjustments", action: _jsx(CtrlBtn, { onClick: addAdjustmentRow, children: "+ Add adjustment" }), children: _jsx("div", { className: "table-scroll", children: _jsx(AdjustmentsTable, { bookings: bookings, adjustments: adjustments, onChange: handleAdjustmentsChange }) }) }), _jsx(Section, { title: "Staff Rota", action: _jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }, children: [_jsxs("button", { onClick: syncRota, 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,
|
||||
}, children: [_jsx(RefreshCw, { size: 11, strokeWidth: 1.75, style: { animation: syncing ? 'spin 1s linear infinite' : 'none' } }), syncing ? 'Syncing…' : 'Sync from Workforce'] }), _jsx("span", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: wfSyncLabel() }), _jsx(CtrlBtn, { onClick: addStaffRow, children: "+ Add staff" })] }), children: _jsx("div", { className: "table-scroll", children: _jsx(StaffTable, { bookings: bookings, staff: staff, required: required, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed, onChange: handleStaffChange, wfShifts: wfShifts, wfStaff: wfStaff }) }) })] }))] }));
|
||||
}, children: [_jsx(RefreshCw, { size: 11, strokeWidth: 1.75, style: { animation: syncing ? 'spin 1s linear infinite' : 'none' } }), syncing ? 'Syncing…' : 'Sync rota'] }), _jsxs("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,
|
||||
}, children: [_jsx(Clock, { size: 11, strokeWidth: 1.75, style: { animation: timesheetSyncing ? 'spin 1s linear infinite' : 'none' } }), timesheetSyncing ? 'Pulling…' : 'Pull timesheets'] }), _jsx("span", { style: { fontSize: '0.72rem', color: 'var(--text-mid)' }, children: wfSyncLabel() }), _jsx(CtrlBtn, { onClick: addStaffRow, children: "+ Add staff" })] }), children: _jsx("div", { className: "table-scroll", children: _jsx(StaffTable, { bookings: bookings, staff: staff, required: required, warnOverRed: warnOverRed, warnOverAmber: warnOverAmber, warnUnderAmber: warnUnderAmber, warnUnderRed: warnUnderRed, onChange: handleStaffChange, wfShifts: wfShifts, wfStaff: wfStaff }) }) })] }))] }));
|
||||
}
|
||||
// ── Section wrapper ───────────────────────────────────────────────────────────
|
||||
function Section({ title, children, action }) {
|
||||
|
|
@ -433,7 +456,7 @@ function pivotWfShifts(wfShifts, dates) {
|
|||
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));
|
||||
|
|
@ -468,7 +491,9 @@ function StaffTable({ bookings, staff, required, warnOverRed, warnOverAmber, war
|
|||
borderRadius: '3px', padding: '0 4px', marginRight: '0.4rem', lineHeight: '1.5',
|
||||
}, children: "WF" }), member.name] }), dates.map(date => {
|
||||
const shift = member.days[date];
|
||||
return (_jsx("td", { style: { textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }, children: shift ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }, children: shift.times }), _jsx("div", { style: { fontWeight: 600 }, children: fmtH(shift.hours) })] })) : _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" }) }, date));
|
||||
const isTimesheet = wfShifts[date]?.source === 'timesheet';
|
||||
const isPending = isTimesheet && shift?.status !== 'APPROVED';
|
||||
return (_jsx("td", { style: { textAlign: 'center', padding: '0.3rem 0.4rem', verticalAlign: 'middle' }, children: shift ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { fontSize: '0.68rem', color: 'var(--text-mid)', lineHeight: 1.25 }, children: shift.times }), _jsxs("div", { style: { fontWeight: 600, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.2rem' }, children: [isTimesheet && (_jsx(Clock, { size: 10, strokeWidth: 1.75, style: { color: isPending ? '#d97706' : '#1a7a4a', flexShrink: 0 } })), fmtH(shift.hours)] })] })) : _jsx("span", { style: { color: 'var(--text-mid)' }, children: "\u2014" }) }, date));
|
||||
}), _jsx("td", {})] }, 'wf-' + member.id))), wfStaff.length > 0 && (_jsx("datalist", { id: "wf-staff-datalist", children: wfStaff.map(s => _jsx("option", { value: s.name }, s.id)) })), staff.map((member, i) => (_jsxs("tr", { children: [_jsx("td", { className: "col-label", style: { padding: '0.3rem 0.5rem' }, children: _jsx("input", { className: "hk-text-input", value: member.name, placeholder: "Staff name", list: wfStaff.length > 0 ? 'wf-staff-datalist' : undefined, onChange: e => setMemberName(i, e.target.value) }) }), dates.map(date => (_jsx("td", { style: { padding: '0.3rem 0.4rem' }, children: _jsx("input", { type: "number", className: "hk-num-input", min: 0, max: 24, step: 0.5, value: member.hours[date] ?? '', placeholder: "0", onChange: e => setMemberHours(i, date, e.target.value) }) }, date))), _jsx("td", { children: _jsx("button", { onClick: () => removeRow(i), style: {
|
||||
background: 'none', border: 'none', color: 'var(--text-mid)',
|
||||
fontSize: '1rem', padding: '0.2rem 0.4rem',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export interface Adjustment {
|
|||
export interface WfShiftDay {
|
||||
hours: number
|
||||
times: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface WfStaffMember {
|
||||
|
|
@ -68,7 +69,7 @@ export interface WfStaffMember {
|
|||
export interface WfDayData {
|
||||
synced_at: string
|
||||
source: string
|
||||
staff: { id: string; name: string; hours: number; times: string }[]
|
||||
staff: { id: string; name: string; hours: number; times: string; status?: string }[]
|
||||
}
|
||||
|
||||
export interface RequiredDay {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue