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:
parent
91936ab131
commit
5372111f52
9 changed files with 289 additions and 16 deletions
|
|
@ -38,6 +38,20 @@ export async function initDb() {
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS wage_scheduled_date_idx ON wage_scheduled(date);
|
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 (
|
CREATE TABLE IF NOT EXISTS wages_config (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL DEFAULT '',
|
value TEXT NOT NULL DEFAULT '',
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,16 @@ async function getDeptNameMap() {
|
||||||
return Object.fromEntries(depts.map(d => [d.id, d.name]))
|
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) {
|
export async function syncActuals(from, to) {
|
||||||
const creds = await getWorkforceCreds()
|
const creds = await getWorkforceCreds()
|
||||||
const locationId = creds.location_id
|
const locationId = creds.location_id
|
||||||
|
|
@ -83,14 +93,19 @@ export async function syncActuals(from, to) {
|
||||||
if (locationId) path += `&report_location_id=${locationId}`
|
if (locationId) path += `&report_location_id=${locationId}`
|
||||||
|
|
||||||
const shifts = await wfFetchPaged(path)
|
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) {
|
for (const s of shifts) {
|
||||||
const deptId = String(s.department_id)
|
const deptId = String(s.department_id)
|
||||||
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
if (enabledDeptIds && !enabledDeptIds.includes(deptId)) continue
|
||||||
|
|
||||||
const date = s.date
|
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}`
|
const key = `${date}:${deptId}`
|
||||||
if (!byDateDept[key]) {
|
if (!byDateDept[key]) {
|
||||||
byDateDept[key] = {
|
byDateDept[key] = {
|
||||||
|
|
@ -102,9 +117,25 @@ export async function syncActuals(from, to) {
|
||||||
shift_count: 0,
|
shift_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
byDateDept[key].base_cost += parseFloat(s.cost ?? 0)
|
byDateDept[key].base_cost += baseCost
|
||||||
byDateDept[key].total_cost += parseFloat(s.cost_with_oncosts ?? s.cost ?? 0)
|
byDateDept[key].total_cost += totalCost
|
||||||
byDateDept[key].shift_count += 1
|
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)) {
|
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
|
return Object.keys(byDateDept).length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,34 @@ export async function actualsRoutes(fastify) {
|
||||||
|
|
||||||
return { departments: Object.values(deptMap), show_oncosts: showOncosts, dept_pcts }
|
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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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 }) })
|
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 {
|
export function downloadExport(view: string, from: string, to: string): void {
|
||||||
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
window.open(`${BASE}/export?view=${view}&from=${from}&to=${to}`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
frontend/src/components/DeptDetailModal.tsx
Normal file
72
frontend/src/components/DeptDetailModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -314,6 +314,58 @@ input[type="text"]:focus {
|
||||||
font-style: italic;
|
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 ─────────────────────────────────────────── */
|
||||||
.py-collapse-toggle {
|
.py-collapse-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
BarChart, Bar, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, Cell,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { getActuals, getScheduled, getNetSales, getBudgets, downloadExport } from '../api'
|
import { getActuals, getScheduled, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
|
||||||
import type { DeptActuals, WageBudget } from '../types'
|
import type { DeptActuals, WageBudget, EmployeeDetail } from '../types'
|
||||||
|
import { DeptDetailModal } from '../components/DeptDetailModal'
|
||||||
|
|
||||||
function fmt(d: Date): string {
|
function fmt(d: Date): string {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
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 [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 dim = daysInMonth(year, month)
|
||||||
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
const monthStr = `${year}-${String(month).padStart(2, '0')}`
|
||||||
|
|
@ -97,6 +101,15 @@ export default function Monthly() {
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
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 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) } }
|
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 dv = depBudg != null ? displayCost - depBudg : null
|
||||||
const depOfTotal = totalDisplay > 0 ? (displayCost / totalDisplay) * 100 : null
|
const depOfTotal = totalDisplay > 0 ? (displayCost / totalDisplay) * 100 : null
|
||||||
return (
|
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><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>
|
<td className="right">{fmtMoney(dep.actual_mtd)}</td>
|
||||||
{isCurrentMonth && <td className="right">{fmtMoney(dep.forecast_eom)}</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ChevronDown, Download } from 'lucide-react'
|
||||||
import { getActuals, getNetSales, getBudgets, downloadExport } from '../api'
|
import { getActuals, getNetSales, getBudgets, getDeptDetail, downloadExport } from '../api'
|
||||||
import type { DeptActuals, WageBudget } from '../types'
|
import type { DeptActuals, WageBudget, EmployeeDetail } from '../types'
|
||||||
|
import { DeptDetailModal } from '../components/DeptDetailModal'
|
||||||
|
|
||||||
function localStr(d: Date): string {
|
function localStr(d: Date): string {
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
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 [showOncosts, setShowOncosts] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true); setError(null)
|
setLoading(true); setError(null)
|
||||||
|
|
@ -123,6 +127,15 @@ export default function Weekly() {
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
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 prev = () => setFromStr(s => addDaysStr(s, -7))
|
||||||
const next = () => 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 depOfTotal = totalWages > 0 ? (dep.cost / totalWages) * 100 : null
|
||||||
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
|
const depSPct = netSales > 0 ? (dep.cost / netSales) * 100 : null
|
||||||
return (
|
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>{dep.department_name}</td>
|
||||||
<td className="right">{fmtMoney(dep.cost)}</td>
|
<td className="right">{fmtMoney(dep.cost)}</td>
|
||||||
<td className="right" style={{ color: 'var(--text-muted)' }}>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,3 +53,10 @@ export interface DeptPct {
|
||||||
name: string
|
name: string
|
||||||
pct: number
|
pct: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmployeeDetail {
|
||||||
|
employee_id: string
|
||||||
|
employee_name: string
|
||||||
|
cost: number
|
||||||
|
shift_count: number
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue