Rework workforce sync to per-date storage with rolling window
Replaces the single workforce_rota config snapshot (which was overwritten on each sync, losing data when switching weeks) with a workforce_daily_shifts table keyed by date. Sync now covers a rolling today-7 to today+28 window unconditionally — no date params needed. Each date's record carries a synced_at timestamp so the UI shows the age of the oldest date in view. Mid-week viewing works naturally since data is stored per date not per week. source column reserved for future timesheet replacement of past dates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a8d8d9a2b
commit
c7066521ff
10 changed files with 278 additions and 178 deletions
|
|
@ -10,6 +10,14 @@ export async function initDb() {
|
|||
value JSONB NOT NULL DEFAULT 'null'::jsonb
|
||||
)
|
||||
`)
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS workforce_daily_shifts (
|
||||
date TEXT PRIMARY KEY,
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
source TEXT NOT NULL DEFAULT 'workforce',
|
||||
staff JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
)
|
||||
`)
|
||||
}
|
||||
|
||||
export async function getConfig(key, defaultVal = null) {
|
||||
|
|
@ -24,3 +32,38 @@ export async function setConfig(key, value) {
|
|||
[key, JSON.stringify(value)]
|
||||
)
|
||||
}
|
||||
|
||||
export async function getWorkforceShifts(start, end) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT date, synced_at, source, staff FROM workforce_daily_shifts
|
||||
WHERE date >= $1 AND date <= $2 ORDER BY date`,
|
||||
[start, end]
|
||||
)
|
||||
const result = {}
|
||||
for (const row of rows) {
|
||||
result[row.date] = { synced_at: row.synced_at, source: row.source, staff: row.staff }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function upsertWorkforceShifts(dailyRows) {
|
||||
if (!dailyRows.length) return
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
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)]
|
||||
)
|
||||
}
|
||||
await client.query('COMMIT')
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK')
|
||||
throw err
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export async function configRoutes(app) {
|
|||
app.get('/api/config', async (req) => {
|
||||
const [
|
||||
timeReqs, staffData, pickupData, generalTasks, lastReviewed,
|
||||
workforceRota, workforceDepts, adjustments,
|
||||
workforceDepts, adjustments,
|
||||
warnOverRed, warnOverAmber, warnUnderAmber, warnUnderRed,
|
||||
] = await Promise.all([
|
||||
getConfig('time_requirements', {}),
|
||||
|
|
@ -18,7 +18,6 @@ export async function configRoutes(app) {
|
|||
getConfig('pickup_data', {}),
|
||||
getConfig('general_tasks', []),
|
||||
getConfig('last_reviewed', null),
|
||||
getConfig('workforce_rota', null),
|
||||
getConfig('workforce_departments', []),
|
||||
getConfig('adjustments', []),
|
||||
getConfig('warn_over_red_hrs', 4),
|
||||
|
|
@ -39,7 +38,6 @@ export async function configRoutes(app) {
|
|||
pickup_data: pickupData || {},
|
||||
general_tasks: generalTasks || [],
|
||||
last_reviewed: lastReviewed || yestStr,
|
||||
workforce_rota: workforceRota || null,
|
||||
workforce_departments: workforceDepts || [],
|
||||
adjustments: adjustments || [],
|
||||
warn_over_red_hrs: warnOverRed != null ? warnOverRed : 4,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { requireAuth, requireCap } from '../auth.js'
|
||||
import { getConfig, setConfig } from '../db.js'
|
||||
import { getConfig, setConfig, getWorkforceShifts, upsertWorkforceShifts } from '../db.js'
|
||||
import { fetchDepartments, fetchStaff, fetchShifts } from '../lib/workforce.js'
|
||||
|
||||
function fmtDate(d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export async function workforceRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// ── GET /api/workforce/departments — list depts for this location ────────────
|
||||
// ── GET /api/workforce/departments ───────────────────────────────────────────
|
||||
|
||||
app.get('/api/workforce/departments', { preHandler: requireCap('settings') }, async (req, reply) => {
|
||||
try {
|
||||
|
|
@ -16,29 +20,58 @@ export async function workforceRoutes(app) {
|
|||
}
|
||||
})
|
||||
|
||||
// ── POST /api/workforce/sync?start=&end= ─────────────────────────────────────
|
||||
// ── POST /api/workforce/sync — rolling window: today-7 → today+28 ───────────
|
||||
|
||||
app.post('/api/workforce/sync', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||
const { start, end } = req.query
|
||||
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
|
||||
|
||||
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 {
|
||||
const staff = await fetchShifts(start, end, deptIds)
|
||||
const snapshot = { last_sync: new Date().toISOString(), dates: [start, end], staff }
|
||||
await setConfig('workforce_rota', snapshot)
|
||||
return snapshot
|
||||
const staffList = await fetchShifts(from, to, deptIds)
|
||||
|
||||
// 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 cur = new Date(fromDate)
|
||||
while (cur <= toDate) {
|
||||
const d = fmtDate(cur)
|
||||
dailyRows.push({ date: d, staff: byDate[d] || [] })
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
||||
await upsertWorkforceShifts(dailyRows)
|
||||
return { ok: true, from, to, dates_synced: dailyRows.length }
|
||||
} catch (err) {
|
||||
const status = err.message.includes('not configured') ? 503 : 502
|
||||
return reply.status(status).send({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── GET /api/workforce/staff — staff list for manual row datalist ─────────────
|
||||
// ── GET /api/workforce/shifts?start=&end= ────────────────────────────────────
|
||||
|
||||
app.get('/api/workforce/shifts', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||
const { start, end } = req.query
|
||||
if (!start || !end) return reply.status(400).send({ error: 'start and end query params required' })
|
||||
return getWorkforceShifts(start, end)
|
||||
})
|
||||
|
||||
// ── GET /api/workforce/staff — cached staff list for manual row datalist ─────
|
||||
|
||||
app.get('/api/workforce/staff', { preHandler: requireCap('planner') }, async (req, reply) => {
|
||||
const deptIds = (await getConfig('workforce_departments', [])) || []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue