From 5276a7544c016c1c3e875f633339b5d8378fa51c Mon Sep 17 00:00:00 2001 From: Edison Date: Fri, 17 Oct 2025 09:52:53 +0800 Subject: [PATCH] follow TZ --- backend/managerRoutes.js | 84 ++++++++++++++++++++------- src/views/ManagerAttendanceRecord.vue | 16 +++-- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js index 73e2f59..f1929e0 100644 --- a/backend/managerRoutes.js +++ b/backend/managerRoutes.js @@ -8,6 +8,42 @@ import ExcelJS from 'exceljs'; export default function(db) { 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 const authenticateJWT = (req, res, next) => { const authHeader = req.headers.authorization; @@ -164,7 +200,9 @@ export default function(db) { // 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) => { 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) { return res.status(400).json({ message: 'Start date and end date are required.' }); } @@ -190,9 +228,19 @@ export default function(db) { const [rows] = await db.execute(query, params); - const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] }); - const csv = json2csvParser.parse(rows); - res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv); +// Format timestamp per TZ as "YYYY-MM-DD HH:mm:ss" +const shaped = rows.map(r => ({ + 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) { console.error('Raw attendance export error:', error); @@ -237,7 +285,9 @@ export default function(db) { router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => { 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) { return res.status(400).json({ message: 'Start date and end date are required.' }); } @@ -274,8 +324,9 @@ export default function(db) { // ---- Group events by worker/day ---- const workByDay = {}; - rows.forEach(row => { - const day = new Date(row.timestamp).toISOString().split('T')[0]; +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, @@ -289,22 +340,11 @@ export default function(db) { } workByDay[row.worker_id].days[day].push({ type: row.event_type, - time: new Date(row.timestamp), + time: ts, qr_code_name: row.qr_code_name }); }); - // ---- Helpers for formatting ---- - 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()]; - }; - // ---- Build ONE row per worker/day (no double rows) ---- const csvData = []; const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows @@ -331,9 +371,9 @@ export default function(db) { username: w.username, full_name: w.full_name, date: day, - day: dayName(day), - clock_in: firstIn ? fmtHM(firstIn.time) : '', - clock_out: lastOut ? fmtHM(lastOut.time) : '', + day: dayNameFromYMD(day), +clock_in: firstIn ? hmInTZ(firstIn.time, TZ) : '', +clock_out: lastOut ? hmInTZ(lastOut.time, TZ) : '', work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '', qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : '' }; diff --git a/src/views/ManagerAttendanceRecord.vue b/src/views/ManagerAttendanceRecord.vue index fedcda7..caa622b 100644 --- a/src/views/ManagerAttendanceRecord.vue +++ b/src/views/ManagerAttendanceRecord.vue @@ -232,12 +232,16 @@ const exportRawRecords = async () => { exportLoading.value = true; 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 { - const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, { - headers: { - 'Authorization': `Bearer ${sessionStorage.getItem('token')}` - } - }); + const response = await fetch( + `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent(tz)}`, + { + 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); @@ -251,7 +255,7 @@ const exportRawRecords = async () => { } catch (_err) { alert('Failed to export records.'); } finally { - exportLoading.value = false; + exportLoading.value = false; } };