Add per-employee dept breakdown via modal popup

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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-23 11:02:11 +00:00
parent 91936ab131
commit 5372111f52
9 changed files with 289 additions and 16 deletions

View file

@ -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')
}

View file

@ -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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-card" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div>
<div className="modal-title">{deptName}</div>
<div className="modal-sub">{period}</div>
</div>
<button className="modal-close" onClick={onClose}>
<X size={18} strokeWidth={1.75} />
</button>
</div>
{loading ? (
<div className="modal-body-state">Loading</div>
) : !employees || employees.length === 0 ? (
<div className="modal-body-state">
No detail data run a sync to populate employee breakdown.
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>Employee</th>
<th className="right">Shifts</th>
<th className="right">Cost</th>
<th className="right">% of Dept</th>
</tr>
</thead>
<tbody>
{employees.map(e => (
<tr key={e.employee_id}>
<td>{e.employee_name}</td>
<td className="right">{e.shift_count}</td>
<td className="right">{fmtMoney(e.cost)}</td>
<td className="right" style={{ color: 'var(--text-muted)' }}>
{total > 0 ? `${((e.cost / total) * 100).toFixed(1)}%` : '—'}
</td>
</tr>
))}
<tr className="total-row">
<td>Total</td>
<td className="right">{totalShifts}</td>
<td className="right">{fmtMoney(total)}</td>
<td className="right">100%</td>
</tr>
</tbody>
</table>
)}
</div>
</div>
)
}

View file

@ -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;

View file

@ -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<string | null>(null)
const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null)
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(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 (
<tr key={dep.department_id}>
<tr key={dep.department_id} style={{ cursor: 'pointer' }}
onClick={() => setModal({ deptId: dep.department_id, deptName: dep.department_name })}>
<td><span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: dep.color, marginRight: 8 }} />{dep.department_name}</td>
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</td>}
@ -366,6 +380,16 @@ export default function Monthly() {
)}
</>
)}
{modal && (
<DeptDetailModal
deptName={modal.deptName}
period={monthLabel}
employees={modalEmps}
loading={modalLoad}
onClose={() => setModal(null)}
/>
)}
</div>
)
}

View file

@ -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<string | null>(null)
const [modal, setModal] = useState<{ deptId: string; deptName: string } | null>(null)
const [modalEmps, setModalEmps] = useState<EmployeeDetail[] | null>(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 (
<tr key={dep.department_name}>
<tr key={dep.department_name} style={{ cursor: 'pointer' }}
onClick={() => setModal({ deptId: dep.department_id, deptName: dep.department_name })}>
<td>{dep.department_name}</td>
<td className="right">{fmtMoney(dep.cost)}</td>
<td className="right" style={{ color: 'var(--text-muted)' }}>
@ -303,6 +317,16 @@ export default function Weekly() {
)}
</>
)}
{modal && (
<DeptDetailModal
deptName={modal.deptName}
period={`${fmtDisplay(fromStr)} ${fmtDisplay(isCurrentWeek ? todayStr : toStr)}`}
employees={modalEmps}
loading={modalLoad}
onClose={() => setModal(null)}
/>
)}
</div>
)
}

View file

@ -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
}