timezone update

This commit is contained in:
Edison
2025-11-03 11:31:34 +08:00
parent b1a8612571
commit 7231310f93
4 changed files with 163 additions and 106 deletions
+8
View File
@@ -0,0 +1,8 @@
// backend/config/db.js
import 'dotenv/config';
export const APP_TIMEZONE =
process.env.APP_TIMEZONE || 'Asia/Kuala_Lumpur'; // default for Nilai
//process.env.APP_TIMEZONE || 'Asia/Jakarta'; // default for Indonesia
// All dates from DB are treated as if they are in this timezone
+76 -65
View File
@@ -4,10 +4,16 @@ import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import ExcelJS from 'exceljs';
import { APP_TIMEZONE } from './config/db.js';
export default function(db) {
const router = express.Router();
router.use((req, _res, next) => {
req.tz = APP_TIMEZONE;
next();
});
/* === TZ helpers (no deps) === */
const _partsToObj = (parts) => parts.reduce((a, p) => (a[p.type] = p.value, a), {});
const _tzOffsetMinutes = (zone, dUtc) => {
@@ -197,56 +203,61 @@ const dayNameFromYMD = (yyyyMmDd) => {
}
});
// 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, tz } = req.query;
const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
// 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 TZ = req.tz;
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 w.username, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes
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.timestamp DESC
`;
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 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);
res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message });
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
});
let workerIdClause = '';
const params = [`${startDate} 00:00:00`, `${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 w.username, w.full_name, cr.event_type, cr.timestamp,
COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes
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.timestamp DESC
`;
const [rows] = await db.execute(query, params);
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.set('X-Export-TZ', TZ);
res.header('Content-Type', 'text/csv')
.attachment(`raw_attendance_${startDate}_to_${endDate}.csv`)
.send(csv);
} catch (error) {
console.error('Raw attendance export error:', error);
res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message });
}
});
router.post('/add-record', checkPermission('edit_workers'), async (req, res) => {
try {
@@ -285,8 +296,8 @@ res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_t
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate, tz } = req.query;
const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
const { workerIds, startDate, endDate } = req.query;
const TZ = req.tz;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
@@ -295,7 +306,7 @@ const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx';
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
@@ -317,15 +328,15 @@ const TZ = tz || process.env.EXPORT_TZ || 'Asia/Kuala_Lumpur';
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
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 => {
// ---- 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]) {
@@ -439,6 +450,7 @@ for (const workerId in workByDay) {
ws.eachRow(row => { row.alignment = { vertical: 'middle' }; });
const buf = await wb.xlsx.writeBuffer();
res.set('X-Export-TZ', TZ);
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));
@@ -449,16 +461,17 @@ for (const workerId in workByDay) {
fields: ['username','full_name','date','day','clock_in','clock_out','work_hours','qr_code_name']
});
const csv = json2csvParser.parse(csvData);
res
res.set('X-Export-TZ', TZ);
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 });
}
});
} 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 {
@@ -492,9 +505,7 @@ for (const workerId in workByDay) {
if (startDate && endDate) {
query += ' AND cr.timestamp BETWEEN ? AND ?';
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
params.push(startDate, endOfDay);
params.push(`${startDate} 00:00:00`, `${endDate} 23:59:59`);
}
query += ' ORDER BY w.full_name, cr.timestamp DESC';
+24 -17
View File
@@ -1,4 +1,7 @@
// server.js
import dotenv from 'dotenv'; //load .env first before anything else
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
import express from 'express';
import cors from 'cors';
import https from 'https';
@@ -6,10 +9,10 @@ import http from 'http';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import mysql from 'mysql2/promise';
import managerRoutes from './managerRoutes.js';
import workerRoutes from './workerRoutes.js';
import { APP_TIMEZONE } from './config/db.js';
async function startServer() {
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
@@ -17,15 +20,19 @@ async function startServer() {
const app = express();
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
});
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// Return date/time columns as strings like "YYYY-MM-DD HH:mm:ss"
dateStrings: ['DATE', 'DATETIME', 'TIMESTAMP'],
});
try {
const connection = await db.getConnection();
@@ -54,24 +61,24 @@ async function startServer() {
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'],
exposedHeaders: ['Content-Range', 'X-Content-Range'],
exposedHeaders: ['Content-Range', 'X-Content-Range', 'X-Export-TZ'],
};
app.use(cors(corsOptions));
app.use(express.json());
// --- Public server time endpoints (no auth, no cache) ---
const timeHandler = (req, res) => {
const now = new Date();
const ymdKL = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Kuala_Lumpur',
year: 'numeric', month: '2-digit', day: '2-digit'
}).format(now); // "YYYY-MM-DD"
const now = new Date();
const ymdTZ = new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TIMEZONE,
year: 'numeric', month: '2-digit', day: '2-digit'
}).format(now); // "YYYY-MM-DD"
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
res.json({ nowIso: now.toISOString(), tz: 'Asia/Kuala_Lumpur', ymdKL });
res.json({ nowIso: now.toISOString(), tz: APP_TIMEZONE, ymdTZ });
};
app.get('/time', timeHandler); // public path
app.get('/api/time', timeHandler); // also under /api
+55 -24
View File
@@ -101,7 +101,7 @@
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }}
{{ formatTimestamp(record.timestamp) }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
<td class="px-4 py-3">
@@ -128,6 +128,18 @@ import { useI18n } from 'vue-i18n'
import { apiFetch } from '@/api.js'
import { workerCache } from '@/utils/workerCache.js'
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
// const tz = localStorage.getItem('tz') || Intl.DateTimeFormat().resolvedOptions().timeZone
const { t } = useI18n()
const route = useRoute()
@@ -135,6 +147,17 @@ const records = ref([])
const workerName = ref('')
const workerId = route.params.workerId
// Simple, safe formatter: if it's ISO, format; if it's a naive DB string, show as-is
const formatTimestamp = (ts) => {
if (!ts) return ''
// ISO-ish string (has 'T' or a timezone offset)
if (ts.includes('T') || /[Z+-]\d{2}:?\d{2}$/.test(ts)) {
const d = new Date(ts)
return isNaN(d) ? ts : d.toLocaleString()
}
return ts
}
const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
@@ -229,35 +252,43 @@ const addManualClockOut = async () => {
}
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;
exportLoading.value = true
const { startDate, endDate } = filters.value
try {
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)}`,
// build query safely (no tz param — backend doesnt use it)
const qs = new URLSearchParams({
startDate,
endDate,
workerIds: String(workerId),
})
const res = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?${qs.toString()}`,
{
headers: { 'Authorization': `Bearer ${sessionStorage.getItem('token')}` }
headers: { Authorization: `Bearer ${sessionStorage.getItem('token')}` },
cache: 'no-store',
}
);
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 = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_err) {
alert('Failed to export records.');
)
if (!res.ok) throw new Error(`Raw export failed: ${res.status}`)
// try to read filename from Content-Disposition
const cd = res.headers.get('content-disposition') || ''
const m = /filename="(.+?)"/i.exec(cd)
const safeWorker = (workerName.value || 'worker').replace(/[^\w.-]+/g, '_')
const fallback = `raw_attendance_${safeWorker}_${startDate}_to_${endDate}.csv`
const filename = m?.[1] || fallback
const blob = await res.blob()
downloadBlob(blob, filename)
} catch (e) {
console.error(e)
alert('Failed to export records.')
} finally {
exportLoading.value = false;
exportLoading.value = false
}
};
}
onMounted(() => {
fetchRecords()