Initial scaffold: wages app

Full wage cost reporting app — weekly/monthly views, rolling 12-week/12-month
history, budget management, Workforce API sync with SSE backfill, net sales
via forecasting public API, department filter, CSV export.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 09:03:52 +00:00
commit 2e0592eb90
37 changed files with 3078 additions and 0 deletions

View file

@ -0,0 +1,229 @@
import { pool, getConfig } from '../db.js'
const SETTINGS_URL = process.env.SETTINGS_URL || 'http://10.10.10.116:3080'
const SETTINGS_SECRET = process.env.SETTINGS_SECRET || ''
let _credsCache = null
async function getWorkforceCreds() {
if (_credsCache && Date.now() < _credsCache.expires_at) return _credsCache.creds
const res = await fetch(`${SETTINGS_URL}/settings/api/internal/integration/workforce`, {
headers: { Authorization: `Bearer ${SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error('Workforce integration not configured — add bearer token in Settings')
const creds = await res.json()
if (!creds.bearer_token) throw new Error('Workforce integration not configured — add bearer token in Settings')
_credsCache = { creds, expires_at: Date.now() + 5 * 60_000 }
return creds
}
async function wfFetch(path) {
const creds = await getWorkforceCreds()
const base = creds.base_url || 'https://my.workforce.com'
const res = await fetch(`${base}${path}`, {
headers: { Authorization: `Bearer ${creds.bearer_token}` },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Workforce API ${res.status}${body ? ': ' + body.slice(0, 200) : ''}`)
}
return res.json()
}
async function wfFetchPaged(path) {
const results = []
let page = 1
while (true) {
const sep = path.includes('?') ? '&' : '?'
const data = await wfFetch(`${path}${sep}page=${page}&page_size=100`)
const items = Array.isArray(data)
? data
: (data.users ?? data.departments ?? data.schedules ?? data.shifts ?? [])
results.push(...items)
if (items.length < 100) break
page++
}
return results
}
export async function fetchAllDepartments() {
const creds = await getWorkforceCreds()
const locationId = creds.location_id ? String(creds.location_id) : null
const all = await wfFetchPaged('/api/v2/departments')
const filtered = locationId ? all.filter(d => String(d.location_id) === locationId) : all
return filtered.map(d => ({ id: String(d.id), name: d.name }))
}
async function getEnabledDeptIds() {
const val = await getConfig('departments')
if (!val) return null // null means "all enabled"
try {
const depts = JSON.parse(val)
if (!Array.isArray(depts) || depts.length === 0) return null
const enabled = depts.filter(d => d.enabled !== false).map(d => d.id)
return enabled.length > 0 ? enabled : null
} catch {
return null
}
}
async function getDeptNameMap() {
const depts = await fetchAllDepartments()
return Object.fromEntries(depts.map(d => [d.id, d.name]))
}
export async function syncActuals(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const enabledDeptIds = await getEnabledDeptIds()
let path = `/api/v2/shifts?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) path += `&report_location_id=${locationId}`
const shifts = await wfFetchPaged(path)
const deptNameMap = await getDeptNameMap()
const byDateDept = {}
for (const s of shifts) {
const deptId = String(s.department_id)
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
const date = s.date
const key = `${date}:${deptId}`
if (!byDateDept[key]) {
byDateDept[key] = {
date,
department_id: deptId,
department_name: deptNameMap[deptId] || s.department_name || deptId,
base_cost: 0,
total_cost: 0,
shift_count: 0,
}
}
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
byDateDept[key].shift_count += 1
}
for (const row of Object.values(byDateDept)) {
await pool.query(
`INSERT INTO wage_actuals (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (date, department_id) DO UPDATE SET
department_name = EXCLUDED.department_name,
base_cost = EXCLUDED.base_cost,
total_cost = EXCLUDED.total_cost,
shift_count = EXCLUDED.shift_count,
cached_at = NOW()`,
[row.date, row.department_id, row.department_name,
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
)
}
return Object.keys(byDateDept).length
}
export async function syncScheduled(from, to) {
const creds = await getWorkforceCreds()
const locationId = creds.location_id
const enabledDeptIds = await getEnabledDeptIds()
let path = `/api/v2/schedules?from=${from}&to=${to}&show_costs=true&include_oncosts=true`
if (locationId) path += `&location_id=${locationId}`
const schedules = await wfFetchPaged(path)
const deptNameMap = await getDeptNameMap()
const byDateDept = {}
for (const s of schedules) {
const deptId = String(s.department_id)
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
const date = s.date || new Date(s.start * 1000).toISOString().slice(0, 10)
const key = `${date}:${deptId}`
if (!byDateDept[key]) {
byDateDept[key] = {
date,
department_id: deptId,
department_name: deptNameMap[deptId] || deptId,
base_cost: 0,
total_cost: 0,
shift_count: 0,
}
}
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
byDateDept[key].shift_count += 1
}
for (const row of Object.values(byDateDept)) {
await pool.query(
`INSERT INTO wage_scheduled (date, department_id, department_name, base_cost, total_cost, shift_count, cached_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (date, department_id) DO UPDATE SET
department_name = EXCLUDED.department_name,
base_cost = EXCLUDED.base_cost,
total_cost = EXCLUDED.total_cost,
shift_count = EXCLUDED.shift_count,
cached_at = NOW()`,
[row.date, row.department_id, row.department_name,
row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count]
)
}
return Object.keys(byDateDept).length
}
export async function runRollingSync() {
const today = new Date()
const to = today.toISOString().slice(0, 10)
const fromDate = new Date(today)
fromDate.setDate(fromDate.getDate() - 35)
const from = fromDate.toISOString().slice(0, 10)
const fwdDate = new Date(today)
fwdDate.setDate(fwdDate.getDate() + 14)
const fwd = fwdDate.toISOString().slice(0, 10)
const [actualRows, scheduledRows] = await Promise.all([
syncActuals(from, to),
syncScheduled(to, fwd),
])
return { actualRows, scheduledRows }
}
export async function runBackfill(onProgress, signal) {
const today = new Date()
const endDate = new Date(today)
endDate.setDate(endDate.getDate() - 1)
const startDate = new Date(today)
startDate.setMonth(startDate.getMonth() - 13)
const totalDays = Math.max(1, Math.ceil((endDate - startDate) / 86_400_000))
let processedDays = 0
let current = new Date(startDate)
while (current <= endDate) {
if (signal?.aborted) break
const weekEnd = new Date(current)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > endDate) weekEnd.setTime(endDate.getTime())
const from = current.toISOString().slice(0, 10)
const to = weekEnd.toISOString().slice(0, 10)
await syncActuals(from, to)
const daysInBatch = Math.ceil((weekEnd - current) / 86_400_000) + 1
processedDays += daysInBatch
onProgress?.({ processed: processedDays, total: totalDays, current: from })
current.setDate(current.getDate() + 7)
await new Promise(r => setTimeout(r, 250))
}
}