timezone update
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 doesn’t 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()
|
||||
|
||||
Reference in New Issue
Block a user