Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f316d8ab | |||
| 5276a7544c | |||
| 80d94f248e | |||
| 356d96a61f | |||
| 411b47e897 | |||
| 8e5fa5652e | |||
| 7473cfccbc | |||
| 0d23a187f0 | |||
| b61e456e2d |
+227
-84
@@ -3,10 +3,47 @@ import { Parser } from 'json2csv';
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
export default function(db) {
|
export default function(db) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
/* === TZ helpers (no deps) === */
|
||||||
|
const _partsToObj = (parts) => parts.reduce((a, p) => (a[p.type] = p.value, a), {});
|
||||||
|
const _tzOffsetMinutes = (zone, dUtc) => {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||||
|
});
|
||||||
|
const p = _partsToObj(fmt.formatToParts(dUtc));
|
||||||
|
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
|
||||||
|
return (asUTC - dUtc.getTime()) / 60000; // e.g. +480 for +08:00
|
||||||
|
};
|
||||||
|
const parseNaiveAsTZ = (s, zone) => {
|
||||||
|
if (s instanceof Date) return s;
|
||||||
|
if (typeof s === 'number') return new Date(s);
|
||||||
|
if (typeof s === 'string' && s.includes('T')) return new Date(s); // ISO with Z/offset
|
||||||
|
const [d, t = '00:00:00'] = String(s).split(' ');
|
||||||
|
const [Y, M, D] = d.split('-').map(Number);
|
||||||
|
const [h, m, sec = 0] = t.split(':').map(Number);
|
||||||
|
const guessUtc = new Date(Date.UTC(Y, M - 1, D, h, m, sec));
|
||||||
|
const off = _tzOffsetMinutes(zone, guessUtc);
|
||||||
|
return new Date(guessUtc.getTime() - off * 60000);
|
||||||
|
};
|
||||||
|
const ymdInTZ = (date, zone) =>
|
||||||
|
new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date);
|
||||||
|
const hmInTZ = (date, zone) =>
|
||||||
|
new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date);
|
||||||
|
const hmsInTZ = (date, zone) =>
|
||||||
|
new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date);
|
||||||
|
const ymdHmsInTZ = (date, zone) => `${ymdInTZ(date, zone)} ${hmsInTZ(date, zone)}`;
|
||||||
|
const dayNameFromYMD = (yyyyMmDd) => {
|
||||||
|
const [y, m, dd] = yyyyMmDd.split('-').map(Number);
|
||||||
|
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()];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Middleware to authenticate and authorize managers
|
// Middleware to authenticate and authorize managers
|
||||||
const authenticateJWT = (req, res, next) => {
|
const authenticateJWT = (req, res, next) => {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -163,7 +200,9 @@ export default function(db) {
|
|||||||
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
|
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
|
||||||
router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => {
|
router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerIds, startDate, endDate } = req.query;
|
const { workerIds, startDate, endDate, tz } = req.query;
|
||||||
|
const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
return res.status(400).json({ message: 'Start date and end date are required.' });
|
return res.status(400).json({ message: 'Start date and end date are required.' });
|
||||||
}
|
}
|
||||||
@@ -189,9 +228,19 @@ export default function(db) {
|
|||||||
|
|
||||||
const [rows] = await db.execute(query, params);
|
const [rows] = await db.execute(query, params);
|
||||||
|
|
||||||
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
|
// Format timestamp per TZ as "YYYY-MM-DD HH:mm:ss"
|
||||||
const csv = json2csvParser.parse(rows);
|
const shaped = rows.map(r => ({
|
||||||
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
|
username: r.username,
|
||||||
|
full_name: r.full_name,
|
||||||
|
event_type: r.event_type,
|
||||||
|
timestamp: ymdHmsInTZ(parseNaiveAsTZ(r.timestamp, TZ), TZ),
|
||||||
|
qr_code_name: r.qr_code_name,
|
||||||
|
notes: r.notes
|
||||||
|
}));
|
||||||
|
|
||||||
|
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
|
||||||
|
const csv = json2csvParser.parse(shaped);
|
||||||
|
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Raw attendance export error:', error);
|
console.error('Raw attendance export error:', error);
|
||||||
@@ -234,88 +283,182 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
|
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workerIds, startDate, endDate } = req.query;
|
const { workerIds, startDate, endDate, tz } = req.query;
|
||||||
if (!startDate || !endDate) {
|
const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
|
||||||
return res.status(400).json({ message: 'Start date and end date are required.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
let workerIdClause = '';
|
if (!startDate || !endDate) {
|
||||||
const params = [startDate, `${endDate} 23:59:59`];
|
return res.status(400).json({ message: 'Start date and end date are required.' });
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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}
|
||||||
|
AND cr.event_type IN ('clock_in','clock_out')
|
||||||
|
ORDER BY cr.worker_id, cr.timestamp ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await db.execute(query, params);
|
||||||
|
|
||||||
|
// ---- Group events by worker/day ----
|
||||||
|
const workByDay = {};
|
||||||
|
rows.forEach(row => {
|
||||||
|
const ts = parseNaiveAsTZ(row.timestamp, TZ);
|
||||||
|
const day = ymdInTZ(ts, TZ);
|
||||||
|
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: ts,
|
||||||
|
qr_code_name: row.qr_code_name
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Build rows: one per successful [clock_in, clock_out] session ----
|
||||||
|
const csvData = [];
|
||||||
|
const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows
|
||||||
|
|
||||||
|
for (const workerId in workByDay) {
|
||||||
|
const w = workByDay[workerId];
|
||||||
|
const perWorkerRows = [];
|
||||||
|
|
||||||
|
for (const day of Object.keys(w.days).sort()) {
|
||||||
|
// events for this day in ascending time
|
||||||
|
const events = w.days[day].slice().sort((a, b) => a.time - b.time);
|
||||||
|
|
||||||
|
let open = null;
|
||||||
|
let openQr = 'Manual Entry';
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.type === 'clock_in' && open == null) {
|
||||||
|
open = e.time;
|
||||||
|
openQr = e.qr_code_name || 'Manual Entry';
|
||||||
|
} else if (e.type === 'clock_out' && open != null) {
|
||||||
|
const start = open;
|
||||||
|
const end = e.time;
|
||||||
|
|
||||||
|
const dailyRow = {
|
||||||
|
username: w.username,
|
||||||
|
full_name: w.full_name,
|
||||||
|
date: day,
|
||||||
|
day: dayNameFromYMD(day),
|
||||||
|
clock_in: hmInTZ(start, TZ),
|
||||||
|
clock_out: hmInTZ(end, TZ),
|
||||||
|
work_hours: ((end - start) / 3600000).toFixed(2),
|
||||||
|
qr_code_name: openQr
|
||||||
|
};
|
||||||
|
|
||||||
|
csvData.push(dailyRow);
|
||||||
|
perWorkerRows.push(dailyRow);
|
||||||
|
|
||||||
|
// close the session
|
||||||
|
open = null;
|
||||||
|
openQr = 'Manual Entry';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
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 [key, rowsForWorker] of byWorkerForXlsx.entries()) {
|
||||||
|
const [username, full_name, dept] = key.split('||');
|
||||||
|
|
||||||
|
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 = dept ? `${username} ${full_name} Dept: ${dept}` : `${username} ${full_name}`;
|
||||||
|
titleCell.font = { bold: true, size: 12 };
|
||||||
|
titleCell.alignment = { horizontal: 'left', vertical: 'middle' };
|
||||||
|
|
||||||
|
// Header row under the group
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// Detail rows (one per day)
|
||||||
|
for (const r of rowsForWorker) {
|
||||||
|
ws.addRow(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: one row per day; include identity columns =====
|
||||||
|
const json2csvParser = new Parser({
|
||||||
|
fields: ['username','full_name','date','day','clock_in','clock_out','work_hours','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) => {
|
router.get('/attendance-records', checkPermission('view_all'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Generated
+896
-6
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@
|
|||||||
"body-parser": "^2.2.0",
|
"body-parser": "^2.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
"json2csv": "^6.0.0-alpha.2",
|
"json2csv": "^6.0.0-alpha.2",
|
||||||
|
|||||||
@@ -516,22 +516,22 @@ const toggleSelectAll = (event) => {
|
|||||||
const exportWorkHours = async () => {
|
const exportWorkHours = async () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
exportLoading.value = true;
|
exportLoading.value = true;
|
||||||
toast.showToast($t('exportingRecords'), 'info');
|
toast.showToast($t('exportingRecords'), 'info');
|
||||||
const { startDate, endDate } = exportFilters.value;
|
const { startDate, endDate } = exportFilters.value;
|
||||||
let workerIds = selectedWorkerIds.value.join(',');
|
let workerIds = selectedWorkerIds.value.join(',');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
|
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: {
|
headers: {
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Network response was not ok.');
|
if (!response.ok) throw new Error('Network response was not ok.');
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `work_hours_${startDate}_to_${endDate}.csv`;
|
a.download = `work_hours_${startDate}_to_${endDate}.xlsx`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
@@ -539,7 +539,7 @@ const exportWorkHours = async () => {
|
|||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
toast.showToast($t('exportRecordsFailed'), 'error');
|
toast.showToast($t('exportRecordsFailed'), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
exportLoading.value = false;
|
exportLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
console.log("[DEBUG] main.js loaded");
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@@ -10,3 +11,5 @@ const app = createApp(App)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
console.log("[DEBUG] i18n import in main.js:", i18n);
|
||||||
|
|||||||
@@ -232,12 +232,16 @@ const exportRawRecords = async () => {
|
|||||||
exportLoading.value = true;
|
exportLoading.value = true;
|
||||||
const { startDate, endDate } = filters.value;
|
const { startDate, endDate } = filters.value;
|
||||||
|
|
||||||
|
// pull preferred tz from localStorage; fall back to browser tz
|
||||||
|
const tz = localStorage.getItem('tz') || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, {
|
const response = await fetch(
|
||||||
headers: {
|
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent(tz)}`,
|
||||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
{
|
||||||
}
|
headers: { 'Authorization': `Bearer ${sessionStorage.getItem('token')}` }
|
||||||
});
|
}
|
||||||
|
);
|
||||||
if (!response.ok) throw new Error('Network response was not ok.');
|
if (!response.ok) throw new Error('Network response was not ok.');
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
@@ -251,7 +255,7 @@ const exportRawRecords = async () => {
|
|||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
alert('Failed to export records.');
|
alert('Failed to export records.');
|
||||||
} finally {
|
} finally {
|
||||||
exportLoading.value = false;
|
exportLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user