add total hours and summary on excel.js

This commit is contained in:
Edison
2025-12-17 08:43:19 +08:00
parent e31416d91d
commit 2e7de997ff
+136 -10
View File
@@ -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:0015: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()
@@ -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
}, {})