follow TZ
This commit is contained in:
+59
-19
@@ -8,6 +8,42 @@ 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;
|
||||||
@@ -164,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.' });
|
||||||
}
|
}
|
||||||
@@ -190,8 +228,18 @@ export default function(db) {
|
|||||||
|
|
||||||
const [rows] = await db.execute(query, params);
|
const [rows] = await db.execute(query, params);
|
||||||
|
|
||||||
|
// 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 json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
|
||||||
const csv = json2csvParser.parse(rows);
|
const csv = json2csvParser.parse(shaped);
|
||||||
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
|
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -237,7 +285,9 @@ 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;
|
||||||
|
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.' });
|
||||||
}
|
}
|
||||||
@@ -275,7 +325,8 @@ export default function(db) {
|
|||||||
// ---- Group events by worker/day ----
|
// ---- Group events by worker/day ----
|
||||||
const workByDay = {};
|
const workByDay = {};
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const day = new Date(row.timestamp).toISOString().split('T')[0];
|
const ts = parseNaiveAsTZ(row.timestamp, TZ);
|
||||||
|
const day = ymdInTZ(ts, TZ);
|
||||||
if (!workByDay[row.worker_id]) {
|
if (!workByDay[row.worker_id]) {
|
||||||
workByDay[row.worker_id] = {
|
workByDay[row.worker_id] = {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -289,22 +340,11 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
workByDay[row.worker_id].days[day].push({
|
workByDay[row.worker_id].days[day].push({
|
||||||
type: row.event_type,
|
type: row.event_type,
|
||||||
time: new Date(row.timestamp),
|
time: ts,
|
||||||
qr_code_name: row.qr_code_name
|
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) ----
|
// ---- Build ONE row per worker/day (no double rows) ----
|
||||||
const csvData = [];
|
const csvData = [];
|
||||||
const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows
|
const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows
|
||||||
@@ -331,9 +371,9 @@ export default function(db) {
|
|||||||
username: w.username,
|
username: w.username,
|
||||||
full_name: w.full_name,
|
full_name: w.full_name,
|
||||||
date: day,
|
date: day,
|
||||||
day: dayName(day),
|
day: dayNameFromYMD(day),
|
||||||
clock_in: firstIn ? fmtHM(firstIn.time) : '',
|
clock_in: firstIn ? hmInTZ(firstIn.time, TZ) : '',
|
||||||
clock_out: lastOut ? fmtHM(lastOut.time) : '',
|
clock_out: lastOut ? hmInTZ(lastOut.time, TZ) : '',
|
||||||
work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '',
|
work_hours: (firstIn && lastOut) ? (totalSec / 3600).toFixed(2) : '',
|
||||||
qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : ''
|
qr_code_name: firstIn ? (firstIn.qr_code_name || 'Manual Entry') : ''
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user