add total hours and summary on excel.js
This commit is contained in:
+137
-11
@@ -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
|
||||
}, {})
|
||||
|
||||
Reference in New Issue
Block a user