excel download reformat on employee list.

This commit is contained in:
Edison
2025-10-13 11:39:50 +08:00
parent 84ce4085a0
commit 9db81d377e
4 changed files with 1122 additions and 94 deletions
+218 -81
View File
@@ -3,6 +3,7 @@ import { Parser } from 'json2csv';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import ExcelJS from 'exceljs';
export default function(db) {
const router = express.Router();
@@ -234,88 +235,224 @@ export default function(db) {
}
})
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT cr.worker_id, w.username, w.full_name, cr.event_type, cr.timestamp
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
ORDER BY cr.worker_id, cr.timestamp ASC
`;
const [rows] = await db.execute(query, params);
const workHoursByWorkerAndDay = {};
rows.forEach(row => {
const day = new Date(row.timestamp).toISOString().split('T')[0];
if (!workHoursByWorkerAndDay[row.worker_id]) {
workHoursByWorkerAndDay[row.worker_id] = {
username: row.username,
full_name: row.full_name,
days: {}
};
}
if (!workHoursByWorkerAndDay[row.worker_id].days[day]) {
workHoursByWorkerAndDay[row.worker_id].days[day] = [];
}
workHoursByWorkerAndDay[row.worker_id].days[day].push({
type: row.event_type,
time: new Date(row.timestamp)
});
});
const csvData = [];
for (const workerId in workHoursByWorkerAndDay) {
const workerData = workHoursByWorkerAndDay[workerId];
for (const day in workerData.days) {
const events = workerData.days[day];
let dailyTotalSeconds = 0;
let lastClockIn = null;
events.forEach(event => {
if (event.type === 'clock_in') {
lastClockIn = event.time;
} else if (event.type === 'clock_out' && lastClockIn) {
dailyTotalSeconds += (event.time - lastClockIn) / 1000;
lastClockIn = null;
}
});
csvData.push({
username: workerData.username,
full_name: workerData.full_name,
date: day,
work_hours: (dailyTotalSeconds / 3600).toFixed(2)
});
}
}
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'date', 'work_hours'] });
const csv = json2csvParser.parse(csvData);
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Work hours export error:', error);
res.status(500).json({ message: 'Database error exporting work hours.', details: error.message });
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
});
const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx';
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT
cr.worker_id,
w.username,
w.full_name,
w.department,
cr.event_type,
cr.timestamp,
COALESCE(qc.name, 'Manual Entry') AS qr_code_name
FROM clock_records cr
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}
ORDER BY cr.worker_id, cr.timestamp ASC
`;
const [rows] = await db.execute(query, params);
const workByDay = {};
const fmtTS = (d) => {
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
// group events
rows.forEach(row => {
const day = new Date(row.timestamp).toISOString().split('T')[0];
if (!workByDay[row.worker_id]) {
workByDay[row.worker_id] = {
username: row.username,
full_name: row.full_name,
department: row.department || '',
days: {}
};
}
if (!workByDay[row.worker_id].days[day]) {
workByDay[row.worker_id].days[day] = [];
}
workByDay[row.worker_id].days[day].push({
type: row.event_type,
time: new Date(row.timestamp),
qr_code_name: row.qr_code_name
});
});
// build two rows per day: first clock_in (no hours) and last clock_out (with hours)
const csvData = [];
const byWorkerForXlsx = new Map();
for (const workerId in workByDay) {
const w = workByDay[workerId];
const perWorkerRows = [];
for (const day of Object.keys(w.days).sort()) {
const events = w.days[day].slice().sort((a, b) => a.time - b.time);
let totalSec = 0;
let open = null;
for (const e of events) {
if (e.type === 'clock_in' && open == null) open = e.time;
else if (e.type === 'clock_out' && open != null) { totalSec += (e.time - open) / 1000; open = null; }
}
const firstIn = events.find(e => e.type === 'clock_in') || null;
const lastOut = [...events].reverse().find(e => e.type === 'clock_out') || null;
if (firstIn) {
const row = {
username: w.username,
full_name: w.full_name,
department: w.department,
date: day,
work_hours: '',
event_type: 'clock_in',
timestamp: fmtTS(firstIn.time),
qr_code_name: firstIn.qr_code_name || 'Manual Entry'
};
csvData.push(row);
perWorkerRows.push(row);
}
if (lastOut) {
const row = {
username: w.username,
full_name: w.full_name,
department: w.department,
date: day,
work_hours: (totalSec / 3600).toFixed(2),
event_type: 'clock_out',
timestamp: fmtTS(lastOut.time),
qr_code_name: lastOut.qr_code_name || 'Manual Entry'
};
csvData.push(row);
perWorkerRows.push(row);
}
}
byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows);
}
// ===== XLSX branch: grouped header + per-day summary columns =====
if (wantXlsx) {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Attendance');
// helper formatters
const fmtHM = (d) => {
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}`;
};
const dayName = (yyyyMmDd) => {
const [y, m, dd] = yyyyMmDd.split('-').map(Number);
const d = new Date(y, m - 1, dd);
return ['SUN','MON','TUE','WED','THU','FRI','SAT'][d.getDay()];
};
ws.columns = [
{ header: 'Date', key: 'date', width: 12 },
{ header: 'Day', key: 'day', width: 8 },
{ header: 'Clock In', key: 'clock_in', width: 10 },
{ 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 },
];
for (const workerId in workByDay) {
const w = workByDay[workerId];
if (ws.lastRow) ws.addRow([]);
// bold merged group header: "username full_name Dept: X"
const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1;
ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`);
const titleCell = ws.getCell(`A${titleRowIdx}`);
titleCell.value = w.department
? `${w.username} ${w.full_name} Dept: ${w.department}`
: `${w.username} ${w.full_name}`;
titleCell.font = { bold: true, size: 12 };
titleCell.alignment = { horizontal: 'left', vertical: 'middle' };
// header row under the group (styled)
const hdr = ws.addRow({
date: 'Date',
day: 'Day',
clock_in: 'Clock In',
clock_out: 'Clock Out',
work_hours: 'Work Hours',
qr_code_name: 'QR Code',
});
hdr.font = { bold: true };
// one row per day: first clock_in, last clock_out, total hours, QR from clock_in
for (const day of Object.keys(w.days).sort()) {
const events = w.days[day].slice().sort((a, b) => a.time - b.time);
// total hours across all in/out pairs
let totalSec = 0;
let open = null;
for (const e of events) {
if (e.type === 'clock_in' && open == null) open = e.time;
else if (e.type === 'clock_out' && open != null) { totalSec += (e.time - open) / 1000; open = null; }
}
const firstIn = events.find(e => e.type === 'clock_in') || null;
const lastOut = [...events].reverse().find(e => e.type === 'clock_out') || null;
ws.addRow({
date: day,
day: dayName(day),
clock_in: firstIn ? fmtHM(firstIn.time) : '',
clock_out: lastOut ? fmtHM(lastOut.time) : '',
work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '',
qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : '',
});
}
}
ws.eachRow(row => { row.alignment = { vertical: 'middle' }; });
const buf = await wb.xlsx.writeBuffer();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`);
return res.send(Buffer.from(buf));
}
// CSV fallback (unchanged field order)
const { Parser } = await import('json2csv');
const json2csvParser = new Parser({
fields: ['username', 'full_name', 'date', 'work_hours', 'event_type', 'timestamp', 'qr_code_name']
});
const csv = json2csvParser.parse(csvData);
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Work hours export error:', error);
res.status(500).json({ message: 'Database error exporting work hours.', details: error.message });
}
});
router.get('/attendance-records', checkPermission('view_all'), async (req, res) => {
try {
+896 -6
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -19,6 +19,7 @@
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"exceljs": "^4.4.0",
"express": "^5.1.0",
"html5-qrcode": "^2.3.8",
"json2csv": "^6.0.0-alpha.2",
+7 -7
View File
@@ -516,22 +516,22 @@ const toggleSelectAll = (event) => {
const exportWorkHours = async () => {
const toast = useToast();
exportLoading.value = true;
toast.showToast($t('exportingRecords'), 'info');
toast.showToast($t('exportingRecords'), 'info');
const { startDate, endDate } = exportFilters.value;
let workerIds = selectedWorkerIds.value.join(',');
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Network response was not ok.');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `work_hours_${startDate}_to_${endDate}.csv`;
a.download = `work_hours_${startDate}_to_${endDate}.xlsx`;
document.body.appendChild(a);
a.click();
a.remove();
@@ -539,7 +539,7 @@ const exportWorkHours = async () => {
} catch (_err) {
toast.showToast($t('exportRecordsFailed'), 'error');
} finally {
exportLoading.value = false;
exportLoading.value = false;
}
};