add total hours and summary on excel.js
This commit is contained in:
+136
-10
@@ -70,6 +70,42 @@ export default function () {
|
|||||||
const d = new Date(y, m - 1, dd, 12, 0, 0, 0) // noon avoids DST edges
|
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()]
|
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
|
// Middleware to authenticate and authorize managers
|
||||||
const authenticateJWT = (req, res, next) => {
|
const authenticateJWT = (req, res, next) => {
|
||||||
@@ -98,7 +134,7 @@ export default function () {
|
|||||||
managerId,
|
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.' })
|
return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' })
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
@@ -414,7 +450,7 @@ export default function () {
|
|||||||
|
|
||||||
// ---- Build rows: one per successful [clock_in, clock_out] session ----
|
// ---- Build rows: one per successful [clock_in, clock_out] session ----
|
||||||
const csvData = []
|
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) {
|
for (const workerId in workByDay) {
|
||||||
const w = workByDay[workerId]
|
const w = workByDay[workerId]
|
||||||
@@ -427,6 +463,9 @@ export default function () {
|
|||||||
let open = null
|
let open = null
|
||||||
let openQr = 'Manual Entry'
|
let openQr = 'Manual Entry'
|
||||||
|
|
||||||
|
const sessions = []
|
||||||
|
const dayRows = []
|
||||||
|
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type === 'clock_in' && open == null) {
|
if (e.type === 'clock_in' && open == null) {
|
||||||
open = e.time
|
open = e.time
|
||||||
@@ -435,6 +474,8 @@ export default function () {
|
|||||||
const start = open
|
const start = open
|
||||||
const end = e.time
|
const end = e.time
|
||||||
|
|
||||||
|
sessions.push({ start, end })
|
||||||
|
|
||||||
const dailyRow = {
|
const dailyRow = {
|
||||||
username: w.username,
|
username: w.username,
|
||||||
full_name: w.full_name,
|
full_name: w.full_name,
|
||||||
@@ -444,24 +485,57 @@ export default function () {
|
|||||||
clock_out: hmInTZ(end, TZ),
|
clock_out: hmInTZ(end, TZ),
|
||||||
work_hours: ((end - start) / 3600000).toFixed(2),
|
work_hours: ((end - start) / 3600000).toFixed(2),
|
||||||
qr_code_name: openQr,
|
qr_code_name: openQr,
|
||||||
|
daily_total: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
csvData.push(dailyRow)
|
csvData.push(dailyRow)
|
||||||
perWorkerRows.push(dailyRow)
|
perWorkerRows.push(dailyRow)
|
||||||
|
dayRows.push(dailyRow)
|
||||||
|
|
||||||
// close the session
|
// close the session
|
||||||
open = null
|
open = null
|
||||||
openQr = 'Manual Entry'
|
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)
|
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) {
|
if (wantXlsx) {
|
||||||
const wb = new ExcelJS.Workbook()
|
const wb = new ExcelJS.Workbook()
|
||||||
const ws = wb.addWorksheet('Attendance')
|
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 = [
|
ws.columns = [
|
||||||
{ header: 'Date', key: 'date', width: 12 },
|
{ header: 'Date', key: 'date', width: 12 },
|
||||||
@@ -470,7 +544,12 @@ export default function () {
|
|||||||
{ header: 'Clock Out', key: 'clock_out', width: 10 },
|
{ header: 'Clock Out', key: 'clock_out', width: 10 },
|
||||||
{ header: 'Work Hours', key: 'work_hours', width: 12 },
|
{ header: 'Work Hours', key: 'work_hours', width: 12 },
|
||||||
{ header: 'QR Code', key: 'qr_code_name', width: 24 },
|
{ 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()) {
|
for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) {
|
||||||
const [username, full_name, dept] = key.split('||')
|
const [username, full_name, dept] = key.split('||')
|
||||||
@@ -479,7 +558,7 @@ export default function () {
|
|||||||
|
|
||||||
// Bold merged group header: "username full_name Dept: X"
|
// Bold merged group header: "username full_name Dept: X"
|
||||||
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1
|
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}`)
|
const titleCell = ws.getCell(`A${titleRowIdx}`)
|
||||||
titleCell.value = dept
|
titleCell.value = dept
|
||||||
? `${username} ${full_name} Dept: ${dept}`
|
? `${username} ${full_name} Dept: ${dept}`
|
||||||
@@ -495,17 +574,63 @@ export default function () {
|
|||||||
clock_out: 'Clock Out',
|
clock_out: 'Clock Out',
|
||||||
work_hours: 'Work Hours',
|
work_hours: 'Work Hours',
|
||||||
qr_code_name: 'QR Code',
|
qr_code_name: 'QR Code',
|
||||||
|
daily_total: 'Daily Total',
|
||||||
})
|
})
|
||||||
hdr.font = { bold: true }
|
hdr.font = { bold: true }
|
||||||
|
|
||||||
// Detail rows (one per day)
|
// Detail rows (one per day)
|
||||||
for (const r of rowsForWorker) {
|
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) => {
|
ws.eachRow((row) => {
|
||||||
row.alignment = { vertical: 'middle' }
|
row.eachCell((cell) => {
|
||||||
|
cell.alignment = { ...(cell.alignment || {}), vertical: 'middle' }
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const buf = await wb.xlsx.writeBuffer()
|
const buf = await wb.xlsx.writeBuffer()
|
||||||
@@ -521,7 +646,7 @@ export default function () {
|
|||||||
return res.send(Buffer.from(buf))
|
return res.send(Buffer.from(buf))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CSV fallback: one row per day; include identity columns =====
|
// ===== CSV fallback =====
|
||||||
const json2csvParser = new Parser({
|
const json2csvParser = new Parser({
|
||||||
fields: [
|
fields: [
|
||||||
'username',
|
'username',
|
||||||
@@ -532,6 +657,7 @@ export default function () {
|
|||||||
'clock_out',
|
'clock_out',
|
||||||
'work_hours',
|
'work_hours',
|
||||||
'qr_code_name',
|
'qr_code_name',
|
||||||
|
'daily_total',
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const csv = json2csvParser.parse(csvData)
|
const csv = json2csvParser.parse(csvData)
|
||||||
@@ -624,11 +750,11 @@ export default function () {
|
|||||||
if (requesterId !== targetId) {
|
if (requesterId !== targetId) {
|
||||||
// If not, check if they have permission to manage permissions
|
// If not, check if they have permission to manage permissions
|
||||||
const [permissionRows] = await db.execute(
|
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],
|
[requesterId],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (permissionRows.length === 0 || !permissionRows[0].can_manage_permissions) {
|
if (permissionRows.length === 0 || !toBool(permissionRows[0].manager_permissions)) {
|
||||||
return res
|
return res
|
||||||
.status(403)
|
.status(403)
|
||||||
.json({ message: "Forbidden: Insufficient permissions to view others' permissions." })
|
.json({ message: "Forbidden: Insufficient permissions to view others' permissions." })
|
||||||
@@ -655,7 +781,7 @@ export default function () {
|
|||||||
// Convert buffer values to booleans
|
// Convert buffer values to booleans
|
||||||
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
|
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
|
||||||
if (key !== 'manager_id') {
|
if (key !== 'manager_id') {
|
||||||
acc[key] = Boolean(value)
|
acc[key] = toBool(value)
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|||||||
Reference in New Issue
Block a user