From 5372111f520d681648dce6ccbc4d8a3ca3429ebc Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 23 Jul 2026 11:02:11 +0000 Subject: [PATCH] Add per-employee dept breakdown via modal popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores per-employee shift costs in wage_actuals_detail (APPROVED shifts only). Sync fetches employee name map from /api/v2/users alongside dept names. Clicking any dept row in Weekly or Monthly opens a modal showing Employee · Shifts · Cost · % of Dept, fetched on demand. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db.js | 14 ++++ backend/src/lib/workforce.js | 64 +++++++++++++++--- backend/src/routes/actuals.js | 30 +++++++++ frontend/src/api.ts | 6 +- frontend/src/components/DeptDetailModal.tsx | 72 +++++++++++++++++++++ frontend/src/index.css | 52 +++++++++++++++ frontend/src/pages/Monthly.tsx | 30 ++++++++- frontend/src/pages/Weekly.tsx | 30 ++++++++- frontend/src/types.ts | 7 ++ 9 files changed, 289 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/DeptDetailModal.tsx diff --git a/backend/src/db.js b/backend/src/db.js index 5679957..eb304d3 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -38,6 +38,20 @@ export async function initDb() { ); CREATE INDEX IF NOT EXISTS wage_scheduled_date_idx ON wage_scheduled(date); + CREATE TABLE IF NOT EXISTS wage_actuals_detail ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + department_id TEXT NOT NULL, + employee_id TEXT NOT NULL, + employee_name TEXT NOT NULL, + base_cost DECIMAL(10,2) NOT NULL DEFAULT 0, + total_cost DECIMAL(10,2) NOT NULL DEFAULT 0, + shift_count INTEGER NOT NULL DEFAULT 0, + cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(date, department_id, employee_id) + ); + CREATE INDEX IF NOT EXISTS wage_actuals_detail_date_dept_idx ON wage_actuals_detail(date, department_id); + CREATE TABLE IF NOT EXISTS wages_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', diff --git a/backend/src/lib/workforce.js b/backend/src/lib/workforce.js index f11d8b5..953d79e 100644 --- a/backend/src/lib/workforce.js +++ b/backend/src/lib/workforce.js @@ -74,6 +74,16 @@ async function getDeptNameMap() { return Object.fromEntries(depts.map(d => [d.id, d.name])) } +async function getUserNameMap() { + const creds = await getWorkforceCreds() + const locationId = creds.location_id ? String(creds.location_id) : null + const all = await wfFetchPaged('/api/v2/users') + const filtered = locationId ? all.filter(u => String(u.location_id) === locationId) : all + return Object.fromEntries( + filtered.map(u => [String(u.id), `${u.first_name || ''} ${u.last_name || ''}`.trim() || `User ${u.id}`]) + ) +} + export async function syncActuals(from, to) { const creds = await getWorkforceCreds() const locationId = creds.location_id @@ -83,28 +93,49 @@ export async function syncActuals(from, to) { if (locationId) path += `&report_location_id=${locationId}` const shifts = await wfFetchPaged(path) - const deptNameMap = await getDeptNameMap() + const [deptNameMap, userNameMap] = await Promise.all([getDeptNameMap(), getUserNameMap()]) - const byDateDept = {} + const byDateDept = {} + const byDateDeptEmp = {} 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}` + const date = s.date + const baseCost = parseFloat(s.cost ?? 0) + const totalCost = parseFloat(s.cost_with_oncosts ?? s.cost ?? 0) + + // aggregate by date+dept + 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, + 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].base_cost += baseCost + byDateDept[key].total_cost += totalCost byDateDept[key].shift_count += 1 + + // detail by date+dept+employee (APPROVED only — pending have cost 0 but clutter the list) + if (s.status === 'APPROVED') { + const empId = String(s.user_id) + const empName = userNameMap[empId] || `Employee ${empId}` + const empKey = `${date}:${deptId}:${empId}` + if (!byDateDeptEmp[empKey]) { + byDateDeptEmp[empKey] = { + date, department_id: deptId, employee_id: empId, employee_name: empName, + base_cost: 0, total_cost: 0, shift_count: 0, + } + } + byDateDeptEmp[empKey].base_cost += baseCost + byDateDeptEmp[empKey].total_cost += totalCost + byDateDeptEmp[empKey].shift_count += 1 + } } for (const row of Object.values(byDateDept)) { @@ -122,6 +153,21 @@ export async function syncActuals(from, to) { ) } + for (const row of Object.values(byDateDeptEmp)) { + await pool.query( + `INSERT INTO wage_actuals_detail (date, department_id, employee_id, employee_name, base_cost, total_cost, shift_count, cached_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (date, department_id, employee_id) DO UPDATE SET + employee_name = EXCLUDED.employee_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.employee_id, row.employee_name, + row.base_cost.toFixed(2), row.total_cost.toFixed(2), row.shift_count] + ) + } + return Object.keys(byDateDept).length } diff --git a/backend/src/routes/actuals.js b/backend/src/routes/actuals.js index 0a37547..51bbfe9 100644 --- a/backend/src/routes/actuals.js +++ b/backend/src/routes/actuals.js @@ -48,4 +48,34 @@ export async function actualsRoutes(fastify) { return { departments: Object.values(deptMap), show_oncosts: showOncosts, dept_pcts } }) + + fastify.get('/api/actuals/dept-detail', { preHandler: requireCap('view') }, async (request, reply) => { + const { dept_id, from, to } = request.query + if (!dept_id || !from || !to) return reply.status(400).send({ error: 'dept_id, from, to required' }) + + const showOncostsRaw = await getConfig('show_oncosts') + const showOncosts = showOncostsRaw !== 'false' + const costCol = showOncosts ? 'total_cost' : 'base_cost' + + const res = await pool.query( + `SELECT employee_id, employee_name, + SUM(base_cost) AS base_cost, + SUM(total_cost) AS total_cost, + SUM(shift_count)::int AS shift_count + FROM wage_actuals_detail + WHERE department_id = $1 AND date >= $2 AND date <= $3 + GROUP BY employee_id, employee_name + ORDER BY SUM(${costCol}) DESC`, + [dept_id, from, to] + ) + + return { + employees: res.rows.map(r => ({ + employee_id: r.employee_id, + employee_name: r.employee_name, + cost: parseFloat(showOncosts ? r.total_cost : r.base_cost), + shift_count: r.shift_count, + })), + } + }) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 93fb78e..0b48865 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting, DeptPct } from './types' +import type { DeptActuals, DeptScheduled, NetSalesDay, WageBudget, AppSetting, DeptPct, EmployeeDetail } from './types' const BASE = '/wages/api' @@ -75,6 +75,10 @@ export function saveDeptPcts(pcts: { id: string; pct: number }[]): Promise<{ ok: return request('/budgets/dept-pcts', { method: 'PUT', body: JSON.stringify({ pcts }) }) } +export function getDeptDetail(deptId: string, from: string, to: string): Promise<{ employees: EmployeeDetail[] }> { + return request(`/actuals/dept-detail?dept_id=${encodeURIComponent(deptId)}&from=${from}&to=${to}`) +} + export function downloadExport(view: string, from: string, to: string): void { window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank') } diff --git a/frontend/src/components/DeptDetailModal.tsx b/frontend/src/components/DeptDetailModal.tsx new file mode 100644 index 0000000..635c647 --- /dev/null +++ b/frontend/src/components/DeptDetailModal.tsx @@ -0,0 +1,72 @@ +import { X } from 'lucide-react' +import type { EmployeeDetail } from '../types' + +interface Props { + deptName: string + period: string + employees: EmployeeDetail[] | null + loading: boolean + onClose: () => void +} + +function fmtMoney(n: number): string { + return `£${Math.round(n).toLocaleString('en-GB')}` +} + +export function DeptDetailModal({ deptName, period, employees, loading, onClose }: Props) { + const total = employees?.reduce((s, e) => s + e.cost, 0) ?? 0 + const totalShifts = employees?.reduce((s, e) => s + e.shift_count, 0) ?? 0 + + return ( +
+
e.stopPropagation()}> +
+
+
{deptName}
+
{period}
+
+ +
+ + {loading ? ( +
Loading…
+ ) : !employees || employees.length === 0 ? ( +
+ No detail data — run a sync to populate employee breakdown. +
+ ) : ( + + + + + + + + + + + {employees.map(e => ( + + + + + + + ))} + + + + + + + +
EmployeeShiftsCost% of Dept
{e.employee_name}{e.shift_count}{fmtMoney(e.cost)} + {total > 0 ? `${((e.cost / total) * 100).toFixed(1)}%` : '—'} +
Total{totalShifts}{fmtMoney(total)}100%
+ )} +
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d5ec75f..903eb99 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -314,6 +314,58 @@ input[type="text"]:focus { font-style: italic; } +/* ── Dept detail modal ──────────────────────────────────────────── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} +.modal-card { + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + width: min(600px, 95vw); + max-height: 85vh; + overflow-y: auto; + padding: 24px; +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} +.modal-title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} +.modal-sub { + font-size: 12px; + color: var(--text-muted); + margin-top: 3px; +} +.modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 0; + line-height: 1; + flex-shrink: 0; +} +.modal-close:hover { color: var(--text-primary); } +.modal-body-state { + text-align: center; + color: var(--text-muted); + padding: 32px 0; + font-size: 14px; +} + /* ── PY collapse toggle ─────────────────────────────────────────── */ .py-collapse-toggle { display: flex; diff --git a/frontend/src/pages/Monthly.tsx b/frontend/src/pages/Monthly.tsx index 1b5d41c..ad6ac1a 100644 --- a/frontend/src/pages/Monthly.tsx +++ b/frontend/src/pages/Monthly.tsx @@ -3,8 +3,9 @@ import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell, } from 'recharts' -import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api' -import type { DeptActuals, WageBudget } from '../types' +import { getActuals, getScheduled, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' +import type { DeptActuals, WageBudget, EmployeeDetail } from '../types' +import { DeptDetailModal } from '../components/DeptDetailModal' function fmt(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` @@ -36,6 +37,9 @@ export default function Monthly() { const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null) + const [modalEmps, setModalEmps] = useState(null) + const [modalLoad, setModalLoad] = useState(false) const dim = daysInMonth(year, month) const monthStr = `${year}-${String(month).padStart(2, '0')}` @@ -97,6 +101,15 @@ export default function Monthly() { useEffect(() => { load() }, [load]) + useEffect(() => { + if (!modal) { setModalEmps(null); return } + setModalLoad(true) + getDeptDetail(modal.deptId, fromStr, isCurrentMonth ? todayStr : toStr) + .then(r => setModalEmps(r.employees)) + .catch(() => setModalEmps([])) + .finally(() => setModalLoad(false)) + }, [modal, fromStr, toStr, todayStr, isCurrentMonth]) + const prev = () => { if (month === 1) { setYear(y => y - 1); setMonth(12) } else { setMonth(m => m - 1) } } const next = () => { if (month === 12) { setYear(y => y + 1); setMonth(1) } else { setMonth(m => m + 1) } } @@ -287,7 +300,8 @@ export default function Monthly() { const dv = depBudg != null ? displayCost - depBudg : null const depOfTotal = totalDisplay > 0 ? (displayCost / totalDisplay) * 100 : null return ( - + setModal({ deptId: dep.department_id, deptName: dep.department_name })}> {dep.department_name} {fmtMoney(dep.actual_mtd)} {isCurrentMonth && {fmtMoney(dep.forecast_eom)}} @@ -366,6 +380,16 @@ export default function Monthly() { )} )} + + {modal && ( + setModal(null)} + /> + )} ) } diff --git a/frontend/src/pages/Weekly.tsx b/frontend/src/pages/Weekly.tsx index 4ca780e..b279527 100644 --- a/frontend/src/pages/Weekly.tsx +++ b/frontend/src/pages/Weekly.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useCallback } from 'react' import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react' -import { getActuals, getNetSales, getBudgets, downloadExport } from '../api' -import type { DeptActuals, WageBudget } from '../types' +import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api' +import type { DeptActuals, WageBudget, EmployeeDetail } from '../types' +import { DeptDetailModal } from '../components/DeptDetailModal' function localStr(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` @@ -66,6 +67,9 @@ export default function Weekly() { const [showOncosts, setShowOncosts] = useState(true) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null) + const [modalEmps, setModalEmps] = useState(null) + const [modalLoad, setModalLoad] = useState(false) const load = useCallback(async () => { setLoading(true); setError(null) @@ -123,6 +127,15 @@ export default function Weekly() { useEffect(() => { load() }, [load]) + useEffect(() => { + if (!modal) { setModalEmps(null); return } + setModalLoad(true) + getDeptDetail(modal.deptId, fromStr, isCurrentWeek ? todayStr : toStr) + .then(r => setModalEmps(r.employees)) + .catch(() => setModalEmps([])) + .finally(() => setModalLoad(false)) + }, [modal, fromStr, toStr, todayStr, isCurrentWeek]) + const prev = () => setFromStr(s => addDaysStr(s, -7)) const next = () => setFromStr(s => addDaysStr(s, 7)) @@ -224,7 +237,8 @@ export default function Weekly() { const depOfTotal = totalWages > 0 ? (dep.cost / totalWages) * 100 : null const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null return ( - + setModal({ deptId: dep.department_id, deptName: dep.department_name })}> {dep.department_name} {fmtMoney(dep.cost)} @@ -303,6 +317,16 @@ export default function Weekly() { )} )} + + {modal && ( + setModal(null)} + /> + )} ) } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 224b6a5..282d055 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -53,3 +53,10 @@ export interface DeptPct { name: string pct: number } + +export interface EmployeeDetail { + employee_id: string + employee_name: string + cost: number + shift_count: number +}