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 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 '',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue