From 2e7de997ff90b28c0fe72faeb0ab581142f4aa48 Mon Sep 17 00:00:00 2001 From: Edison Date: Wed, 17 Dec 2025 08:43:19 +0800 Subject: [PATCH] add total hours and summary on excel.js --- backend/managerRoutes.js | 148 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 137 insertions(+), 11 deletions(-) diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js index c4acd20..4af93bb 100644 --- a/backend/managerRoutes.js +++ b/backend/managerRoutes.js @@ -70,6 +70,42 @@ export default function () { const d = new Date(y, m - 1, dd, 12, 0, 0, 0) // noon avoids DST edges return ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'][d.getDay()] } + // --- bool helper (handles BIT(1) returning Buffer) --- + const toBool = (v) => + typeof v === 'number' + ? v === 1 + : Buffer.isBuffer(v) + ? v[0] === 1 + : Boolean(v) + + // ---- Lunch / totals helpers ---- + const minutesBetween = (a, b) => Math.max(0, (b - a) / 60000) + + const overlapMinutes = (aStart, aEnd, bStart, bEnd) => { + const start = Math.max(aStart.getTime(), bStart.getTime()) + const end = Math.min(aEnd.getTime(), bEnd.getTime()) + return Math.max(0, (end - start) / 60000) + } + + // Measures total "gap between sessions" that overlaps lunch window (11:00–15:00), capped to policyMin. + const calcLunchGapMinutes = (sessions, ymd, TZ, policyMin) => { + if (!sessions || sessions.length < 2) return 0 + + const lunchStart = parseNaiveAsTZ(`${ymd} 11:00:00`, TZ) + const lunchEnd = parseNaiveAsTZ(`${ymd} 15:00:00`, TZ) + + const s = sessions.slice().sort((x, y) => x.start - y.start) + + let total = 0 + for (let i = 0; i < s.length - 1; i++) { + const gapStart = s[i].end + const gapEnd = s[i + 1].start + if (gapEnd <= gapStart) continue + total += overlapMinutes(gapStart, gapEnd, lunchStart, lunchEnd) + if (total >= policyMin) return policyMin + } + return Math.min(total, policyMin) + } // Middleware to authenticate and authorize managers const authenticateJWT = (req, res, next) => { @@ -98,7 +134,7 @@ export default function () { managerId, ]) - if (rows.length === 0 || !rows[0][requiredPermission]) { + if (rows.length === 0 || !toBool(rows[0][requiredPermission])) { return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' }) } next() @@ -383,7 +419,7 @@ export default function () { JOIN workers w ON cr.worker_id = w.id LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} - AND cr.event_type IN ('clock_in','clock_out') + AND cr.event_type IN ('clock_in','clock_out') ORDER BY cr.worker_id, cr.timestamp ASC ` @@ -414,7 +450,7 @@ export default function () { // ---- Build rows: one per successful [clock_in, clock_out] session ---- const csvData = [] - const byWorkerForXlsx = new Map() // key = "username||full_name||department" → daily rows + const byWorkerForXlsx = new Map() // key = "username||full_name||department" → session rows for (const workerId in workByDay) { const w = workByDay[workerId] @@ -427,6 +463,9 @@ export default function () { let open = null let openQr = 'Manual Entry' + const sessions = [] + const dayRows = [] + for (const e of events) { if (e.type === 'clock_in' && open == null) { open = e.time @@ -435,6 +474,8 @@ export default function () { const start = open const end = e.time + sessions.push({ start, end }) + const dailyRow = { username: w.username, full_name: w.full_name, @@ -444,24 +485,57 @@ export default function () { clock_out: hmInTZ(end, TZ), work_hours: ((end - start) / 3600000).toFixed(2), qr_code_name: openQr, + daily_total: '', } csvData.push(dailyRow) perWorkerRows.push(dailyRow) + dayRows.push(dailyRow) // close the session open = null openQr = 'Manual Entry' } } + + // ---- Daily total: worked - missing lunch (policy 60min) ---- + if (dayRows.length) { + const unpaidLunchMin = 60 + + const workedMin = sessions.reduce((sum, s) => sum + minutesBetween(s.start, s.end), 0) + const lunchGapMin = calcLunchGapMinutes(sessions, day, TZ, unpaidLunchMin) + + const missingLunchMin = Math.max(0, unpaidLunchMin - lunchGapMin) + const paidMin = Math.max(0, workedMin - missingLunchMin) + + dayRows[dayRows.length - 1].daily_total = (paidMin / 60).toFixed(2) + } } byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows) } - // ===== XLSX branch: grouped header + per-day summary columns ===== + + // ===== XLSX branch: grouped header + per-day summary sheet ===== if (wantXlsx) { const wb = new ExcelJS.Workbook() const ws = wb.addWorksheet('Attendance') + const wsSum = wb.addWorksheet('Summary') + + wsSum.columns = [ + { header: 'Username', key: 'username', width: 16 }, + { header: 'Full Name', key: 'full_name', width: 24 }, + { header: 'Department', key: 'department', width: 18 }, + { header: 'Days', key: 'days', width: 8 }, + { header: 'Worked Hours', key: 'worked_hours', width: 14 }, + { header: 'Paid Hours', key: 'paid_hours', width: 14 }, + { header: 'Avg Paid/Day', key: 'avg_paid', width: 14 }, + ] + wsSum.getRow(1).font = { bold: true } + wsSum.views = [{ state: 'frozen', ySplit: 1 }] + wsSum.autoFilter = { from: 'A1', to: 'G1' } + wsSum.getColumn('worked_hours').numFmt = '0.00' + wsSum.getColumn('paid_hours').numFmt = '0.00' + wsSum.getColumn('avg_paid').numFmt = '0.00' ws.columns = [ { header: 'Date', key: 'date', width: 12 }, @@ -470,7 +544,12 @@ export default function () { { header: 'Clock Out', key: 'clock_out', width: 10 }, { header: 'Work Hours', key: 'work_hours', width: 12 }, { header: 'QR Code', key: 'qr_code_name', width: 24 }, + { header: 'Daily Total', key: 'daily_total', width: 12 }, ] + ws.getColumn('work_hours').numFmt = '0.00' + ws.getColumn('daily_total').numFmt = '0.00' + ws.getColumn('work_hours').alignment = { horizontal: 'right' } + ws.getColumn('daily_total').alignment = { horizontal: 'right' } for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) { const [username, full_name, dept] = key.split('||') @@ -479,7 +558,7 @@ export default function () { // Bold merged group header: "username full_name Dept: X" const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1 - ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`) + ws.mergeCells(`A${titleRowIdx}:G${titleRowIdx}`) const titleCell = ws.getCell(`A${titleRowIdx}`) titleCell.value = dept ? `${username} ${full_name} Dept: ${dept}` @@ -495,17 +574,63 @@ export default function () { clock_out: 'Clock Out', work_hours: 'Work Hours', qr_code_name: 'QR Code', + daily_total: 'Daily Total', }) hdr.font = { bold: true } // Detail rows (one per day) for (const r of rowsForWorker) { - ws.addRow(r) + ws.addRow({ + ...r, + work_hours: r.work_hours === '' ? null : Number(r.work_hours), + daily_total: r.daily_total === '' ? null : Number(r.daily_total), + }) } + + const totalPaid = rowsForWorker.reduce((sum, r) => { + const v = Number(r.daily_total) + return sum + (Number.isFinite(v) ? v : 0) + }, 0) + + const totalWorked = rowsForWorker.reduce((sum, r) => { + const v = Number(r.work_hours) + return sum + (Number.isFinite(v) ? v : 0) + }, 0) + + const daysCount = rowsForWorker.reduce((set, r) => { + if (r.daily_total) set.add(r.date) + return set + }, new Set()).size + + wsSum.addRow({ + username, + full_name, + department: dept || '', + days: daysCount, + worked_hours: totalWorked, + paid_hours: totalPaid, + avg_paid: daysCount ? totalPaid / daysCount : 0, + }) + + const totalRow = ws.addRow({ + date: '', + day: '', + clock_in: '', + clock_out: 'TOTAL', + work_hours: '', + qr_code_name: '', + daily_total: totalPaid, + }) + totalRow.font = { bold: true } + totalRow.eachCell((cell) => { + cell.border = { top: { style: 'thin' } } + }) } ws.eachRow((row) => { - row.alignment = { vertical: 'middle' } + row.eachCell((cell) => { + cell.alignment = { ...(cell.alignment || {}), vertical: 'middle' } + }) }) const buf = await wb.xlsx.writeBuffer() @@ -521,7 +646,7 @@ export default function () { return res.send(Buffer.from(buf)) } - // ===== CSV fallback: one row per day; include identity columns ===== + // ===== CSV fallback ===== const json2csvParser = new Parser({ fields: [ 'username', @@ -532,6 +657,7 @@ export default function () { 'clock_out', 'work_hours', 'qr_code_name', + 'daily_total', ], }) const csv = json2csvParser.parse(csvData) @@ -624,11 +750,11 @@ export default function () { if (requesterId !== targetId) { // If not, check if they have permission to manage permissions const [permissionRows] = await db.execute( - 'SELECT can_manage_permissions FROM manager_permissions WHERE manager_id = ?', + 'SELECT manager_permissions FROM manager_permissions WHERE manager_id = ?', [requesterId], ) - if (permissionRows.length === 0 || !permissionRows[0].can_manage_permissions) { + if (permissionRows.length === 0 || !toBool(permissionRows[0].manager_permissions)) { return res .status(403) .json({ message: "Forbidden: Insufficient permissions to view others' permissions." }) @@ -655,7 +781,7 @@ export default function () { // Convert buffer values to booleans const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => { if (key !== 'manager_id') { - acc[key] = Boolean(value) + acc[key] = toBool(value) } return acc }, {})