6 Commits

Author SHA1 Message Date
Edison 05d58b0012 frontend timezone follow db 2025-11-03 16:48:13 +08:00
Edison b6352dcddc backend done 2025-11-03 16:15:06 +08:00
Edison b1a8612571 added for multiple clock in and out sessions on export 2025-10-17 10:38:08 +08:00
Edison ea9ba5eefc follow TZ 2025-10-17 10:38:00 +08:00
Edison e5bdcbd631 no fall back 2025-10-15 11:15:45 +08:00
Edison 9db81d377e excel download reformat on employee list. 2025-10-15 11:15:19 +08:00
12 changed files with 2039 additions and 442 deletions
+227 -84
View File
@@ -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 {
+2
View File
@@ -25,6 +25,8 @@ async function startServer() {
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
timezone: 'Z',
dateStrings: true
}); });
try { try {
+113 -87
View File
@@ -2,7 +2,12 @@ import express from 'express';
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf'; import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
// Removed unused import import { db } from './server.js';
import { withTzSession } from './middleware/withTzSession.js';
// Map IANA (no DST for KL/Jakarta)
const sessionOffset = (iana) => (iana === 'Asia/Jakarta' ? '+07:00' : '+08:00');
async function validateDeviceForUser(userId, deviceUuid, db) { async function validateDeviceForUser(userId, deviceUuid, db) {
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]); const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
@@ -15,9 +20,10 @@ async function validateDeviceForUser(userId, deviceUuid, db) {
return { valid: device_uuid === deviceUuid, message: 'Device validation failed' }; return { valid: device_uuid === deviceUuid, message: 'Device validation failed' };
} }
async function isClockingEnabled(db) { async function isClockingEnabled(conn) {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD format const [rows] = await conn.execute(
const [rows] = await db.execute('SELECT 1 FROM enabled_dates WHERE enabled_date = ? LIMIT 1', [today]); 'SELECT 1 FROM enabled_dates WHERE enabled_date = CURDATE() LIMIT 1'
);
return rows.length > 0; return rows.length > 0;
} }
@@ -94,95 +100,115 @@ export default function(db) {
}; };
router.use(authenticateJWT); router.use(authenticateJWT);
// Definitive version with distance calculation and specific error messages
// Definitive version with distance calculation and specific error messages
router.post('/clock', async (req, res) => { router.post('/clock', async (req, res) => {
try { // NEW: borrow a connection so we can set session time_zone
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body; const conn = await db.getConnection();
const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' '); try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
// 1. Kill Switch Enforcement // NEW: set session time_zone from header (defaults to KL)
const clockingAllowed = await isClockingEnabled(db); const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur';
if (!clockingAllowed) { await conn.query('SET time_zone = ?', [sessionOffset(iana)]);
const note = 'Clock-in/out function is not enabled for today.';
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)',
[userId, qrCodeValue, latitude, longitude, note, currentTimestamp]
);
return res.status(403).json({ message: 'error.clockingDisabled' });
}
// 2. Geofence Validation with Distance Calculation // 1) Kill Switch — now evaluated in the session's local day
if (latitude != null && longitude != null) { const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn
const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); if (!clockingAllowed) {
const note = 'Clock-in/out function is not enabled for today.';
if (activeFences.length === 0) { await conn.execute( // CHANGED: use conn
const note = 'Cannot clock in: No active work area is defined.'; `INSERT INTO clock_records
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]); (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
return res.status(403).json({ message: 'error.noActiveGeofence' }); VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
} [userId, qrCodeValue, latitude, longitude, note]
const userLocation = point([longitude, latitude]);
const parsedPolygons = [];
let isInside = false;
for (const fence of activeFences) {
try {
if (!fence.coordinates) continue;
const coordinates = JSON.parse(fence.coordinates);
const fencePolygon = polygon([coordinates]);
parsedPolygons.push(fencePolygon); // Save for distance calculation
if (booleanPointInPolygon(userLocation, fencePolygon)) {
isInside = true;
break;
}
} catch (e) {
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
}
}
if (!isInside) {
let minDistance = Infinity;
for (const p of parsedPolygons) {
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
if (distance < minDistance) {
minDistance = distance;
}
}
const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`;
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
}
}
// 3. QR Code and Status Validation
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'error.invalidQrCode' });
}
}
const [lastEvent] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) {
const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut';
return res.status(400).json({ message: errorKey });
}
// 4. Record Successful Event
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
[userId, eventType, qrCodeValue, latitude, longitude, currentTimestamp]
); );
res.status(201).json({ message: 'Clock event recorded.' }); return res.status(403).json({ message: 'error.clockingDisabled' });
} catch (error) {
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
res.status(500).json({ message: 'error.criticalServer' });
} }
});
// 2) Geofence Validation (unchanged logic, just switch db -> conn)
if (latitude != null && longitude != null) {
const [activeFences] = await conn.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED
if (activeFences.length === 0) {
const note = 'Cannot clock in: No active work area is defined.';
await conn.execute( // CHANGED
`INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
[userId, qrCodeValue, latitude, longitude, note]
);
return res.status(403).json({ message: 'error.noActiveGeofence' });
}
const userLocation = point([longitude, latitude]);
const parsedPolygons = [];
let isInside = false;
for (const fence of activeFences) {
try {
if (!fence.coordinates) continue;
const coordinates = JSON.parse(fence.coordinates);
const fencePolygon = polygon([coordinates]);
parsedPolygons.push(fencePolygon);
if (booleanPointInPolygon(userLocation, fencePolygon)) {
isInside = true;
break;
}
} catch (e) {
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
}
}
if (!isInside) {
let minDistance = Infinity;
for (const p of parsedPolygons) {
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
if (distance < minDistance) minDistance = distance;
}
const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`;
await conn.execute( // CHANGED
`INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
[userId, qrCodeValue, latitude, longitude, note]
);
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
}
}
// 3) QR Code and Status Validation (switch db -> conn; logic unchanged)
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
const [qrRows] = await conn.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'error.invalidQrCode' });
}
}
const [lastEvent] = await conn.execute( // CHANGED
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId]
);
if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) {
const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut';
return res.status(400).json({ message: errorKey });
}
// 4) Record Successful Event — store UTC via SQL conversion (no JS date math)
await conn.execute(
`INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
VALUES (?, ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
[userId, eventType, qrCodeValue, latitude, longitude]
);
res.status(201).json({ message: 'Clock event recorded.' });
} catch (error) {
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
res.status(500).json({ message: 'error.criticalServer' });
} finally {
if (conn) conn.release();
}
});
router.get('/workers/:id', async (req, res) => { router.get('/workers/:id', async (req, res) => {
const { id } = req.params; const { id } = req.params;
+896 -6
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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",
+19 -3
View File
@@ -1,11 +1,21 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function getUserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
} catch {
return 'Asia/Kuala_Lumpur';
}
}
export async function apiFetch(endpoint, options = {}) { export async function apiFetch(endpoint, options = {}) {
const token = sessionStorage.getItem('token'); const token = sessionStorage.getItem('token');
const defaultHeaders = { const defaultHeaders = {
'ngrok-skip-browser-warning': 'true', 'ngrok-skip-browser-warning': 'true',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
// Timezone header used by the backend to set session time_zone
'X-User-Timezone': getUserTimezone(),
...options.headers, ...options.headers,
}; };
@@ -42,12 +52,18 @@ export async function apiFetch(endpoint, options = {}) {
document.dispatchEvent(event); document.dispatchEvent(event);
} }
// Use the 'details' from our backend error structure, or the message, or a default // Use the 'details' from our backend error structure, or the message, or a default
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`); throw new Error(
errorData.details ||
errorData.message ||
`API call failed with status: ${response.status}`
);
} else { } else {
// If the server sends back HTML or plain text, use that as the error message. // If the server sends back HTML or plain text, use that as the error message.
// This prevents the "Unexpected token '<'" error. // This prevents the "Unexpected token '<'" error.
const textError = await response.text(); const textError = await response.text();
throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`); throw new Error(
textError || `Server returned an unhandled error with status: ${response.status}`
);
} }
} }
@@ -59,7 +75,7 @@ export async function apiFetch(endpoint, options = {}) {
// Handle file downloads like CSV // Handle file downloads like CSV
const disposition = response.headers.get('content-disposition'); const disposition = response.headers.get('content-disposition');
if (disposition && disposition.includes('attachment')) { if (disposition && disposition.includes('attachment')) {
return response.blob(); return response.blob();
} }
return response.json(); return response.json();
+95 -35
View File
@@ -6,11 +6,17 @@
{{ monthYear }} {{ monthYear }}
</h2> </h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button @click="prevMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <button @click="prevMonth"
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg> class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button> </button>
<button @click="nextMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <button @click="nextMonth"
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg> class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -20,9 +26,8 @@
</div> </div>
<div class="grid grid-cols-7 gap-1"> <div class="grid grid-cols-7 gap-1">
<div v-for="day in calendarGrid" :key="day.id" <div v-for="day in calendarGrid" :key="day.id" @click="day.isCurrentMonth && onDayClick(day)"
@click="day.isCurrentMonth && onDayClick(day)" :class="getDayClasses(day)">
:class="getDayClasses(day)">
{{ day.date }} {{ day.date }}
</div> </div>
</div> </div>
@@ -52,10 +57,12 @@
</div> </div>
</div> </div>
<div class="mt-6 flex flex-col sm:flex-row gap-3"> <div class="mt-6 flex flex-col sm:flex-row gap-3">
<button @click="applyChanges" :disabled="!hasPendingChanges" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"> <button @click="applyChanges" :disabled="!hasPendingChanges"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('applyChanges') }} {{ $t('applyChanges') }}
</button> </button>
<button @click="discardChanges" :disabled="!hasPendingChanges" class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"> <button @click="discardChanges" :disabled="!hasPendingChanges"
class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('discardChanges') }} {{ $t('discardChanges') }}
</button> </button>
</div> </div>
@@ -73,18 +80,30 @@ const { t: $t } = useI18n();
const toast = useToast(); const toast = useToast();
const viewDate = ref(new Date()); const viewDate = ref(new Date());
// Server-driven KL date for the yellow ring (updates every 60s) // Server-driven "today" string (YYYY-MM-DD) for the yellow ring
const todayStr = ref(null); const todayStr = ref(null);
const TZ = 'Asia/Kuala_Lumpur'; // --- timezone handling
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
} catch {
return 'Asia/Kuala_Lumpur';
}
};
const TZ = getUserTimezone();
// Helper: format YYYY-MM-DD in a given TZ // Helper: format YYYY-MM-DD in a given TZ
const ymdInTZ = (tz, d = new Date()) => const ymdInTZ = (tz, d = new Date()) =>
new Intl.DateTimeFormat('en-CA', { new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d); }).format(d);
// Pull today from server; try /api/time then /time; fallback to client KL // Pull today from server; try /api/time then /time; fallback to client TZ
async function getServerDate() { async function getServerDate() {
const parse = (data) => { const parse = (data) => {
if (typeof data?.ymdKL === 'string') return data.ymdKL; if (typeof data?.ymdKL === 'string') return data.ymdKL;
@@ -93,36 +112,49 @@ async function getServerDate() {
}; };
for (const path of ['/api/time', '/time']) { for (const path of ['/api/time', '/time']) {
try { try {
const d = await apiFetch(`${path}?_t=${Date.now()}`); const d = await apiFetch(`${path}?_t=${Date.now()}`);
const y = parse(d); const y = parse(d);
if (y) return y; if (y) return y;
} catch (_err) { } catch (_err) {
continue; // try next endpoint continue;
}
} }
}
console.warn('Server time unavailable; using client KL time.'); console.warn('Server time unavailable; using client time.');
return ymdInTZ(TZ, new Date()); return ymdInTZ(TZ, new Date());
} }
let _intervalId; let _intervalId;
onMounted(async () => { onMounted(async () => {
const update = async () => { todayStr.value = await getServerDate(); }; const update = async () => {
todayStr.value = await getServerDate();
};
await update(); await update();
_intervalId = setInterval(update, 60_000); _intervalId = setInterval(update, 60_000);
}); });
onUnmounted(() => { if (_intervalId) clearInterval(_intervalId); }); onUnmounted(() => {
if (_intervalId) clearInterval(_intervalId);
});
const originalEnabledDates = ref(new Set()); const originalEnabledDates = ref(new Set());
const datesToEnable = ref(new Set()); const datesToEnable = ref(new Set());
const datesToDisable = ref(new Set()); const datesToDisable = ref(new Set());
const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0); const hasPendingChanges = computed(
() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0
);
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort()); const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort()); const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' })); const monthYear = computed(() =>
viewDate.value.toLocaleString('default', {
month: 'long',
year: 'numeric',
timeZone: TZ,
})
);
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const calendarGrid = computed(() => { const calendarGrid = computed(() => {
@@ -148,7 +180,17 @@ const getDayClasses = (day) => {
if (!day.isCurrentMonth) return 'h-20'; if (!day.isCurrentMonth) return 'h-20';
const dateStr = day.id; const dateStr = day.id;
const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative']; const classes = [
'h-20',
'flex',
'items-center',
'justify-center',
'text-lg',
'rounded-lg',
'cursor-pointer',
'transition-colors',
'relative',
];
let isEnabled = originalEnabledDates.value.has(dateStr); let isEnabled = originalEnabledDates.value.has(dateStr);
if (datesToEnable.value.has(dateStr)) isEnabled = true; if (datesToEnable.value.has(dateStr)) isEnabled = true;
@@ -161,7 +203,18 @@ const getDayClasses = (day) => {
classes.push('bg-blue-500', 'text-white', 'font-bold'); classes.push('bg-blue-500', 'text-white', 'font-bold');
} else if (isPendingDisable) { } else if (isPendingDisable) {
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200'); classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
classes.push('after:content-[\'\']', 'after:absolute', 'after:w-3/4', 'after:h-0.5', 'after:bg-red-500', 'after:left-1/2', 'after:top-1/2', 'after:-translate-x-1/2', 'after:-translate-y-1/2', 'after:rotate-[-10deg]'); classes.push(
'after:content-[\'\']',
'after:absolute',
'after:w-3/4',
'after:h-0.5',
'after:bg-red-500',
'after:left-1/2',
'after:top-1/2',
'after:-translate-x-1/2',
'after:-translate-y-1/2',
'after:rotate-[-10deg]'
);
} else if (isEnabled) { } else if (isEnabled) {
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200'); classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
} else { } else {
@@ -169,8 +222,8 @@ const getDayClasses = (day) => {
} }
if (todayStr.value && dateStr === todayStr.value) { if (todayStr.value && dateStr === todayStr.value) {
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500'); classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
} }
return classes; return classes;
}; };
@@ -191,7 +244,7 @@ function onDayClick(day) {
} }
async function applyChanges() { async function applyChanges() {
const confirmed = await toast.showConfirm($t('confirmApplyChanges')) const confirmed = await toast.showConfirm($t('confirmApplyChanges'));
if (!confirmed) return; if (!confirmed) return;
try { try {
@@ -216,12 +269,19 @@ function discardChanges() {
datesToDisable.value.clear(); datesToDisable.value.clear();
} }
const prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1)); const prevMonth = () =>
const nextMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1)); (viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1)));
const nextMonth = () =>
(viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1)));
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, { const formatDate = (dateStr) =>
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
}); weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: TZ,
});
async function fetchEnabledDates() { async function fetchEnabledDates() {
try { try {
+264 -114
View File
@@ -4,28 +4,44 @@
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2> <h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end"> <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') }}</label> <label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName')
<input type="text" id="fullName" v-model="newWorker.fullName" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJohnSmith')" /> }}</label>
<input type="text" id="fullName" v-model="newWorker.fullName"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egJohnSmith')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username') }}</label> <label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username')
<input type="text" id="username" v-model="newWorker.username" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJsmith')" /> }}</label>
<input type="text" id="username" v-model="newWorker.username"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egJsmith')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password') }}</label> <label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password')
<input type="password" id="password" v-model="newWorker.password" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('eg123456')" /> }}</label>
<input type="password" id="password" v-model="newWorker.password"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('eg123456')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department') }}</label> <label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department')
<input type="text" id="department" v-model="newWorker.department" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egSales')" /> }}</label>
<input type="text" id="department" v-model="newWorker.department"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egSales')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label> <label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position')
<input type="text" id="position" v-model="newWorker.position" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egManager')" /> }}</label>
<input type="text" id="position" v-model="newWorker.position"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egManager')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label> <label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</label>
<button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"> <button @click="addWorker" :disabled="!isFormValid || loading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ loading ? $t('adding') : $t('addUser') }} {{ loading ? $t('adding') : $t('addUser') }}
</button> </button>
</div> </div>
@@ -37,21 +53,28 @@
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2> <h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between"> <div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
<div class="flex-grow"> <div class="flex-grow">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> <input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div> </div>
<div class="flex items-end gap-4"> <div class="flex items-end gap-4">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label> <label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
<input type="date" id="export-start-date" v-model="exportFilters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> $t('startDate') }}</label>
</div> <input type="date" id="export-start-date" v-model="exportFilters.startDate"
<div class="flex flex-col gap-2"> class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label> </div>
<input type="date" id="export-end-date" v-model="exportFilters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> <div class="flex flex-col gap-2">
</div> <label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
<button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50"> }}</label>
{{ exportLoading ? $t('exporting') : $t('exportAll') }} <input type="date" id="export-end-date" v-model="exportFilters.endDate"
</button> class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<button @click="exportWorkHours"
:disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
</button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -59,20 +82,40 @@
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600"> <tr class="border-b border-gray-200 dark:border-gray-600">
<th class="w-12 px-2 py-3 text-center"> <th class="w-12 px-2 py-3 text-center">
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" class="form-checkbox h-4 w-4 text-blue-600 rounded" /> <input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('fullName') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('username') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('department') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('position') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('status') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('dateJoined') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
{{ $t('actions') }}
</th> </th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('fullName') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('status') }}</th> <th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="worker in workers" :key="worker.id" :class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }" class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"> <tr v-for="worker in workers" :key="worker.id"
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-2 py-3 text-center"> <td class="px-2 py-3 text-center">
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" class="form-checkbox h-4 w-4 text-blue-600 rounded" /> <input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)"
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
@@ -87,41 +130,61 @@
{{ worker.status }} {{ worker.status }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white">
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap"> {{ formatLocalDate(worker.created_at) }}
<button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button> </td>
<button @click="openSettingsModal(worker)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1"> <td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <button @click="viewRecords(worker.id)"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> {{ $t('viewRecords') }}
</button>
<button @click="openSettingsModal(worker)"
class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
{{ $t('settings') }} {{ $t('settings') }}
</button> </button>
</td> </td>
</tr> </tr>
<tr v-if="workers.length === 0"> <tr v-if="workers.length === 0">
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400"> {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }} <td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <div v-if="totalPages > 1"
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button> class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
{{ $t('previous') }}
</button>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> <input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage"
class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span> <span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
</div> </div>
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button> <button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
{{ $t('next') }}
</button>
</div> </div>
</section> </section>
<div v-if="isSettingsModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4"> <div v-if="isSettingsModalVisible"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3> <h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3>
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> <button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -133,31 +196,47 @@
<div class="mb-4 space-y-4"> <div class="mb-4 space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<input type="text" v-model="editingWorker.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> {{ $t('department') }}
</label>
<input type="text" v-model="editingWorker.department"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<input type="text" v-model="editingWorker.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> {{ $t('position') }}
</label>
<input type="text" v-model="editingWorker.position"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('changePassword') }}
</label>
<div class="space-y-3"> <div class="space-y-3">
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> <input type="password" v-model="newPassword" :placeholder="$t('newPassword')"
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('workerStatus') }}</h4> <h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p> {{ $t('workerStatus') }}
</h4>
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('activeAccount') }}
</p>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer"> <input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div> <div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600">
</div>
</label> </label>
</div> </div>
</div> </div>
@@ -167,19 +246,26 @@
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5> <h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5>
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('clearDeviceDescription') }}</p> <p class="text-xs text-red-600 dark:text-red-400/80">
{{ $t('clearDeviceDescription') }}
</p>
</div> </div>
<button @click="showClearDeviceConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32"> <button @click="showClearDeviceConfirm = true"
class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
{{ $t('clearDevice') }} {{ $t('clearDevice') }}
</button> </button>
</div> </div>
<div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md"> <div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmClearDevice') }}</p> <p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
{{ $t('confirmClearDevice') }}
</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button @click="showClearDeviceConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <button @click="showClearDeviceConfirm = false"
class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{{ $t('cancel') }} {{ $t('cancel') }}
</button> </button>
<button @click="clearDevice(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors"> <button @click="clearDevice(editingWorker.id)"
class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
{{ $t('confirm') }} {{ $t('confirm') }}
</button> </button>
</div> </div>
@@ -190,19 +276,26 @@
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div> <div>
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5> <h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p> <p class="text-xs text-red-600 dark:text-red-400/80">
{{ $t('deleteDescription') }}
</p>
</div> </div>
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32"> <button @click="showDeleteConfirm = true"
class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
{{ $t('delete') }} {{ $t('delete') }}
</button> </button>
</div> </div>
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md"> <div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p> <p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
{{ $t('confirmDelete') }}
</p>
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"> <button @click="showDeleteConfirm = false"
class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{{ $t('cancel') }} {{ $t('cancel') }}
</button> </button>
<button @click="deleteWorker(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors"> <button @click="deleteWorker(editingWorker.id)"
class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
{{ $t('confirm') }} {{ $t('confirm') }}
</button> </button>
</div> </div>
@@ -211,25 +304,33 @@
</div> </div>
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center"> <div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">{{ passwordErrorMessage }}</p> <p v-if="passwordErrorMessage" class="text-red-500 text-sm">
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">{{ passwordSuccessMessage }}</p> {{ passwordErrorMessage }}
</p>
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">
{{ passwordSuccessMessage }}
</p>
</div> </div>
<button @click="saveWorkerSettings" :disabled="passwordLoading" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50"> <button @click="saveWorkerSettings" :disabled="passwordLoading"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
{{ passwordLoading ? $t('saving') : $t('saveChanges') }} {{ passwordLoading ? $t('saving') : $t('saveChanges') }}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4"> <div v-if="isConfirmModalVisible"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-sm"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-sm">
<h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3> <h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3>
<div class="flex justify-end gap-3 mt-6"> <div class="flex justify-end gap-3 mt-6">
<button @click="closeConfirmModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors"> <button @click="closeConfirmModal"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
{{ $t('cancel') }} {{ $t('cancel') }}
</button> </button>
<button @click="executeConfirmedAction" class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors"> <button @click="executeConfirmedAction"
class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
{{ $t('confirm') }} {{ $t('confirm') }}
</button> </button>
</div> </div>
@@ -248,11 +349,46 @@ import { useI18n } from 'vue-i18n';
import { workerCache } from '@/utils/workerCache.js'; import { workerCache } from '@/utils/workerCache.js';
const { t: $t } = useI18n(); const { t: $t } = useI18n();
const router = useRouter(); const router = useRouter();
// --- timezone helpers (for consistent local display + export header) ---
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
} catch {
return 'Asia/Kuala_Lumpur';
}
};
const formatLocalDate = (utcValue) => {
if (!utcValue) return '';
const tz = getUserTimezone();
let iso = utcValue;
if (utcValue instanceof Date) {
iso = utcValue.toISOString();
} else if (typeof utcValue === 'string') {
if (!iso.endsWith('Z')) {
if (iso.includes('T')) {
iso = iso + 'Z';
} else {
iso = iso.replace(' ', 'T') + 'Z';
}
}
}
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
const viewRecords = (workerId) => { const viewRecords = (workerId) => {
// Save current search state before navigating away
const searchState = { const searchState = {
searchQuery: searchQuery.value, searchQuery: searchQuery.value,
currentPage: currentPage.value, currentPage: currentPage.value,
@@ -260,7 +396,7 @@ const viewRecords = (workerId) => {
totalWorkers: totalWorkers.value, totalWorkers: totalWorkers.value,
workers: workers.value, workers: workers.value,
selectedWorkerIds: selectedWorkerIds.value, selectedWorkerIds: selectedWorkerIds.value,
exportFilters: exportFilters.value exportFilters: exportFilters.value,
}; };
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState)); sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
@@ -271,7 +407,13 @@ const viewRecords = (workerId) => {
const workers = ref([]); const workers = ref([]);
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' }); const newWorker = ref({
fullName: '',
username: '',
password: '',
department: '',
position: '',
});
const searchQuery = ref(''); const searchQuery = ref('');
const currentPage = ref(1); const currentPage = ref(1);
const pageSize = ref(20); const pageSize = ref(20);
@@ -290,15 +432,20 @@ const confirmMessage = ref('');
const isConfirmModalVisible = ref(false); const isConfirmModalVisible = ref(false);
const exportFilters = ref({ startDate: '', endDate: '' }); const exportFilters = ref({ startDate: '', endDate: '' });
const exportLoading = ref(false); const exportLoading = ref(false);
// Removed workerStatusLoading as it's no longer needed with integrated save const showClearDeviceConfirm = ref(false);
const showDeleteConfirm = ref(false);
// --- COMPUTED --- // --- COMPUTED ---
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password); const isFormValid = computed(
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password
);
const totalPages = computed(() => { const totalPages = computed(() => {
const pages = Math.ceil(totalWorkers.value / pageSize.value); const pages = Math.ceil(totalWorkers.value / pageSize.value);
return pages < 1 ? 1 : pages; // Ensure at least 1 page return pages < 1 ? 1 : pages;
}); });
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length); const isAllSelected = computed(
() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
);
// --- WATCHERS --- // --- WATCHERS ---
watch(searchQuery, () => fetchWorkers(1)); watch(searchQuery, () => fetchWorkers(1));
@@ -311,18 +458,17 @@ watch(currentPage, (newPage) => {
const fetchWorkers = async (page = currentPage.value) => { const fetchWorkers = async (page = currentPage.value) => {
loading.value = true; loading.value = true;
try { try {
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`); const data = await apiFetch(
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`
);
workers.value = data.workers; workers.value = data.workers;
totalWorkers.value = data.totalCount; totalWorkers.value = data.totalCount;
// Cache worker data
if (data.workers && Array.isArray(data.workers)) { if (data.workers && Array.isArray(data.workers)) {
data.workers.forEach(worker => { data.workers.forEach((worker) => {
workerCache.storeWorkerData(worker.id, worker); workerCache.storeWorkerData(worker.id, worker);
}); });
} }
// currentPage is already set to the requested page before fetch
} catch (_err) { } catch (_err) {
errorMessage.value = 'Failed to fetch workers.'; errorMessage.value = 'Failed to fetch workers.';
workers.value = []; workers.value = [];
@@ -355,12 +501,18 @@ const addWorker = async () => {
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try {
await apiFetch('/api/managers/workers', { await apiFetch('/api/managers/workers', {
method: 'POST', method: 'POST',
body: JSON.stringify({ ...newWorker.value, role: 'worker' }), body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
}); });
await fetchWorkers(1); await fetchWorkers(1);
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' }; newWorker.value = {
fullName: '',
username: '',
password: '',
department: '',
position: '',
};
toast.showToast($t('workerAdded'), 'success'); toast.showToast($t('workerAdded'), 'success');
} catch (_err) { } catch (_err) {
toast.showToast(_err.message || $t('addUserError'), 'error'); toast.showToast(_err.message || $t('addUserError'), 'error');
@@ -376,7 +528,9 @@ const deleteWorker = async (id) => {
try { try {
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' }); await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
toast.showToast($t('workerSoftDeleted'), 'success'); toast.showToast($t('workerSoftDeleted'), 'success');
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value); fetchWorkers(
workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value
);
} catch (_err) { } catch (_err) {
errorMessage.value = 'Failed to soft-delete worker.'; errorMessage.value = 'Failed to soft-delete worker.';
} }
@@ -392,16 +546,15 @@ const clearDevice = async (workerId) => {
} }
}; };
// Renamed and refactored updateWorkerPassword to saveWorkerSettings
const saveWorkerSettings = async () => { const saveWorkerSettings = async () => {
const toast = useToast(); const toast = useToast();
passwordErrorMessage.value = ''; passwordErrorMessage.value = '';
passwordSuccessMessage.value = ''; passwordSuccessMessage.value = '';
let passwordUpdated = false; let passwordUpdated = false;
let detailsUpdated = false; let detailsUpdated = false;
toast.showToast($t('savingSettings'), 'info');
// Handle password change toast.showToast($t('savingSettings'), 'info');
if (newPassword.value || confirmNewPassword.value) { if (newPassword.value || confirmNewPassword.value) {
if (newPassword.value !== confirmNewPassword.value) { if (newPassword.value !== confirmNewPassword.value) {
passwordErrorMessage.value = 'Passwords do not match.'; passwordErrorMessage.value = 'Passwords do not match.';
@@ -414,9 +567,9 @@ const saveWorkerSettings = async () => {
passwordUpdated = true; passwordUpdated = true;
} }
// Handle details change (status, department, position) const originalWorker = workers.value.find((w) => w.id === editingWorker.value.id);
const originalWorker = workers.value.find(w => w.id === editingWorker.value.id);
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive'; const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
if ( if (
originalWorker.status !== newStatus || originalWorker.status !== newStatus ||
originalWorker.department !== editingWorker.value.department || originalWorker.department !== editingWorker.value.department ||
@@ -468,7 +621,7 @@ const saveWorkerSettings = async () => {
}; };
const openSettingsModal = (worker) => { const openSettingsModal = (worker) => {
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox editingWorker.value = { ...worker, isActive: worker.status === 'active' };
isSettingsModalVisible.value = true; isSettingsModalVisible.value = true;
}; };
@@ -483,9 +636,6 @@ const closeSettingsModal = () => {
showDeleteConfirm.value = false; showDeleteConfirm.value = false;
}; };
const showClearDeviceConfirm = ref(false);
const showDeleteConfirm = ref(false);
const closeConfirmModal = () => { const closeConfirmModal = () => {
isConfirmModalVisible.value = false; isConfirmModalVisible.value = false;
confirmAction.value = ''; confirmAction.value = '';
@@ -510,28 +660,32 @@ const toggleWorkerSelection = (workerId) => {
}; };
const toggleSelectAll = (event) => { const toggleSelectAll = (event) => {
selectedWorkerIds.value = event.target.checked ? workers.value.map(w => w.id) : []; selectedWorkerIds.value = event.target.checked ? workers.value.map((w) => w.id) : [];
}; };
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(','); const 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')}`,
} 'X-User-Timezone': getUserTimezone(),
}); },
}
);
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,12 +693,11 @@ 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;
} }
}; };
onMounted(() => { onMounted(() => {
// Check if there's saved search state
const savedSearchState = sessionStorage.getItem('personnelSearchState'); const savedSearchState = sessionStorage.getItem('personnelSearchState');
if (savedSearchState) { if (savedSearchState) {
try { try {
@@ -556,11 +709,8 @@ onMounted(() => {
workers.value = searchState.workers || []; workers.value = searchState.workers || [];
selectedWorkerIds.value = searchState.selectedWorkerIds || []; selectedWorkerIds.value = searchState.selectedWorkerIds || [];
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' }; exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
// Clear the saved search state after restoring it
sessionStorage.removeItem('personnelSearchState'); sessionStorage.removeItem('personnelSearchState');
} catch (_e) { } catch (_e) {
// If there's an error parsing the saved state, fetch workers normally
fetchWorkers(); fetchWorkers();
} }
} else { } else {
+114 -29
View File
@@ -4,18 +4,24 @@
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2> <h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"> <div class="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex-grow"> <div class="flex-grow">
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> <input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
<div class="flex items-end gap-4 flex-wrap"> <div class="flex items-end gap-4 flex-wrap">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label> <label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate')
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> }}</label>
<input type="date" id="start-date" v-model="filters.startDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label> <label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate')
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" /> }}</label>
<input type="date" id="end-date" v-model="filters.endDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div> </div>
<button @click="fetchFailedRecords" :disabled="loadingReport" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50"> <button @click="fetchFailedRecords" :disabled="loadingReport"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
{{ loadingReport ? $t('loading') : $t('fetchRecords') }} {{ loadingReport ? $t('loading') : $t('fetchRecords') }}
</button> </button>
</div> </div>
@@ -24,23 +30,40 @@
<table class="min-w-[700px] w-full text-left"> <table class="min-w-[700px] w-full text-left">
<thead class="bg-gray-100 dark:bg-gray-700"> <thead class="bg-gray-100 dark:bg-gray-700">
<tr class="border-b-2 border-gray-200 dark:border-gray-600"> <tr class="border-b-2 border-gray-200 dark:border-gray-600">
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer" @click="sortBy('full_name')"> <th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer"
@click="sortBy('full_name')">
{{ $t('worker') }} {{ $t('worker') }}
<span v-if="sortField === 'full_name'" class="ml-1">{{ sortDirection === 'asc' ? '' : '' }}</span> <span v-if="sortField === 'full_name'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th> </th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center" @click="sortBy('count')"> <th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center"
@click="sortBy('count')">
{{ $t('failedCount') }} {{ $t('failedCount') }}
<span v-if="sortField === 'count'" class="ml-1">{{ sortDirection === 'asc' ? '' : '' }}</span> <span v-if="sortField === 'count'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">
{{ $t('actions') }}
</th> </th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">{{ $t('actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="record in sortedFailedRecords" :key="record.worker_id" class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150"> <tr v-for="record in sortedFailedRecords" :key="record.worker_id"
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">{{ record.full_name }}</td> class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">{{ record.count }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white font-medium">
{{ record.full_name }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">
{{ record.count }}
</td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<button @click="showDetails(record.worker_id, record.full_name)" class="text-blue-600 dark:text-blue-400 hover:underline"> <button @click="showDetails(record.worker_id, record.full_name)"
class="text-blue-600 dark:text-blue-400 hover:underline">
{{ $t('viewDetails') }} {{ $t('viewDetails') }}
</button> </button>
</td> </td>
@@ -53,9 +76,12 @@
<tr v-if="loadingReport"> <tr v-if="loadingReport">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400"> <td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg> </svg>
<span>{{ $t('loadingReport') }}</span> <span>{{ $t('loadingReport') }}</span>
</div> </div>
@@ -69,8 +95,11 @@
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4"> <div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-4 border-b pb-3"> <div class="flex justify-between items-center mb-4 border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">{{ detailModalTitle }}</h3> <h3 class="text-xl font-semibold text-gray-800 dark:text-white">
<button @click="showDetailModal = false" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none"> {{ detailModalTitle }}
</h3>
<button @click="showDetailModal = false"
class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
&times; &times;
</button> </button>
</div> </div>
@@ -78,22 +107,37 @@
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('timestamp') }}</th> <th
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('eventType') }}</th> class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('location') }}</th> {{ $t('timestamp') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('notes') }}</th> <th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ $t('eventType') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ $t('location') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ $t('notes') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="detail in detailRecords" :key="detail.id"> <tr v-for="detail in detailRecords" :key="detail.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ new Date(detail.timestamp).toLocaleString() }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"> {{ formatLocalTimestamp(detail.timestamp) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
<span
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
{{ $t(detail.event_type) }} {{ $t(detail.event_type) }}
</span> </span>
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.qrCodeUsedName || $t('nA') }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.notes || $t('nA') }}</td> {{ detail.qrCodeUsedName || $t('nA') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
{{ detail.notes || $t('nA') }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -112,6 +156,47 @@ import { useToast } from '@/composables/useToast';
const { t: $t } = useI18n(); const { t: $t } = useI18n();
const toast = useToast(); const toast = useToast();
// --- timezone-aware formatter (local helper) ---
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
} catch {
return 'Asia_Kuala_Lumpur';
}
};
const formatLocalTimestamp = (utcValue) => {
if (!utcValue) return '';
const tz = getUserTimezone();
let iso = utcValue;
if (utcValue instanceof Date) {
iso = utcValue.toISOString();
} else if (typeof utcValue === 'string') {
if (!iso.endsWith('Z')) {
if (iso.includes('T')) {
iso = iso + 'Z';
} else {
iso = iso.replace(' ', 'T') + 'Z';
}
}
}
const d = new Date(iso);
return d.toLocaleString(undefined, {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
};
// --- STATE --- // --- STATE ---
const searchQuery = ref(''); const searchQuery = ref('');
const filters = ref({ startDate: '', endDate: '' }); const filters = ref({ startDate: '', endDate: '' });
@@ -151,7 +236,7 @@ const fetchFailedRecords = async () => {
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`; const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
failedRecords.value = await apiFetch(url); failedRecords.value = await apiFetch(url);
} catch (_err) { } catch (_err) {
console.error('Failed to fetch failed records',_err); console.error('Failed to fetch failed records', _err);
toast.showToast('Failed to fetch records.', 'error'); toast.showToast('Failed to fetch records.', 'error');
} finally { } finally {
loadingReport.value = false; loadingReport.value = false;
@@ -174,7 +259,7 @@ const showDetails = async (workerId, workerName) => {
detailRecords.value = await apiFetch(url); detailRecords.value = await apiFetch(url);
showDetailModal.value = true; showDetailModal.value = true;
} catch (_err) { } catch (_err) {
console.error('Failed to fetch details',_err); console.error('Failed to fetch details', _err);
toast.showToast('Failed to load details.', 'error'); toast.showToast('Failed to load details.', 'error');
} }
}; };
+50
View File
@@ -0,0 +1,50 @@
// src/utils/time.js
// Same logic as apiFetch and KillSwitchManagement
export function getUserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur';
} catch {
return 'Asia_Kuala_Lumpur';
}
}
// utcValue can be: "2025-11-03 16:30:00", ISO string, or Date
export function formatUtcToLocal(utcValue, options = {}) {
if (!utcValue) return '';
const tz = options.timeZone || getUserTimezone();
const locale = options.locale || 'en-MY';
let d;
if (utcValue instanceof Date) {
d = utcValue;
} else if (typeof utcValue === 'string') {
// Normalize: DB gives "YYYY-MM-DD HH:mm:ss" (UTC) turn into ISO UTC
let iso = utcValue;
if (!iso.endsWith('Z')) {
if (iso.includes('T')) {
iso = iso + 'Z';
} else {
iso = iso.replace(' ', 'T') + 'Z';
}
}
d = new Date(iso);
} else if (typeof utcValue === 'number') {
d = new Date(utcValue);
} else {
return '';
}
return d.toLocaleString(locale, {
timeZone: tz,
year: options.year ?? 'numeric',
month: options.month ?? '2-digit',
day: options.day ?? '2-digit',
hour: options.hour ?? '2-digit',
minute: options.minute ?? '2-digit',
second: options.second ?? '2-digit',
hour12: options.hour12 ?? false,
});
}
+172 -68
View File
@@ -19,20 +19,38 @@
</p> </p>
<div class="flex flex-col sm:flex-row gap-4 items-end"> <div class="flex flex-col sm:flex-row gap-4 items-end">
<div class="flex flex-col gap-2 flex-grow w-full"> <div class="flex flex-col gap-2 flex-grow w-full">
<label for="manual-timestamp" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <label
$t('clockOutTime') }}</label> for="manual-timestamp"
<input type="datetime-local" id="manual-timestamp" v-model="manualClockOut.timestamp" class="text-sm font-medium text-gray-700 dark:text-gray-300"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> >
{{ $t('clockOutTime') }}
</label>
<input
type="datetime-local"
id="manual-timestamp"
v-model="manualClockOut.timestamp"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div> </div>
<div class="flex flex-col gap-2 flex-grow w-full"> <div class="flex flex-col gap-2 flex-grow w-full">
<label for="manual-notes" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('reason') <label
}}</label> for="manual-notes"
<input type="text" id="manual-notes" v-model="manualClockOut.notes" class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ $t('reason') }}
</label>
<input
type="text"
id="manual-notes"
v-model="manualClockOut.notes"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('enterBriefNote')" /> :placeholder="$t('enterBriefNote')"
/>
</div> </div>
<button @click="addManualClockOut" <button
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"> @click="addManualClockOut"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
{{ $t('addRecord') }} {{ $t('addRecord') }}
</button> </button>
</div> </div>
@@ -40,23 +58,45 @@
<div class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700"> <div class="flex flex-col sm:flex-row gap-4 items-end mb-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col gap-2 w-full sm:w-auto"> <div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') <label
}}</label> for="start-date"
<input type="date" id="start-date" v-model="filters.startDate" class="text-sm font-medium text-gray-700 dark:text-gray-300"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> >
{{ $t('startDate') }}
</label>
<input
type="date"
id="start-date"
v-model="filters.startDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div> </div>
<div class="flex flex-col gap-2 w-full sm:w-auto"> <div class="flex flex-col gap-2 w-full sm:w-auto">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label> <label
<input type="date" id="end-date" v-model="filters.endDate" for="end-date"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> class="text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ $t('endDate') }}
</label>
<input
type="date"
id="end-date"
v-model="filters.endDate"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
/>
</div> </div>
<button @click="fetchRecords" <button
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"> @click="fetchRecords"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"
>
{{ $t('filterRecords') }} {{ $t('filterRecords') }}
</button> </button>
<button @click="exportRawRecords" :disabled="exportLoading" <button
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50"> @click="exportRawRecords"
{{ exportLoading ? $t('exporting') : $t('export') }} :disabled="exportLoading"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50"
>
{{ exportLoading ? $t('exporting') : $t('export') }}
</button> </button>
</div> </div>
@@ -87,8 +127,11 @@
{{ $t('noRecordsFound') }} {{ $t('noRecordsFound') }}
</td> </td>
</tr> </tr>
<tr v-for="record in records" :key="record.id" <tr
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"> v-for="record in records"
:key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150"
>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span <span
class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white" class="inline-block px-2 py-1 rounded-md text-xs font-semibold uppercase whitespace-nowrap text-white"
@@ -96,23 +139,34 @@
'bg-green-500': record.event_type === 'clock_in', 'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out', 'bg-red-500': record.event_type === 'clock_out',
'bg-yellow-500': record.event_type === 'failed', 'bg-yellow-500': record.event_type === 'failed',
}"> }"
>
{{ record.event_type.replace('_', ' ') }} {{ record.event_type.replace('_', ' ') }}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white"> <td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }} {{ formatLocalTimestamp(record.timestamp) }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.qrCodeUsedName }}
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.qrCodeUsedName }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<a v-if="record.latitude && record.longitude" <a
:href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`" target="_blank" v-if="record.latitude && record.longitude"
rel="noopener noreferrer" class="text-blue-600 hover:text-blue-800 underline font-medium"> :href="`https://maps.google.com/?q=${record.latitude},${record.longitude}`"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 underline font-medium"
>
{{ $t('showOnMap') }} {{ $t('showOnMap') }}
</a> </a>
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span> <span v-else class="text-gray-500 dark:text-gray-400">
{{ $t('nA') }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.notes || $t('nA') }}
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -135,15 +189,60 @@ const records = ref([])
const workerName = ref('') const workerName = ref('')
const workerId = route.params.workerId const workerId = route.params.workerId
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'
} catch {
return 'Asia/Kuala_Lumpur'
}
}
const normalizeUtcToIso = (utcValue) => {
if (!utcValue) return null
if (utcValue instanceof Date) {
return utcValue.toISOString()
}
let iso = utcValue
if (typeof iso === 'string') {
if (!iso.endsWith('Z')) {
if (iso.includes('T')) {
iso = iso + 'Z'
} else {
iso = iso.replace(' ', 'T') + 'Z'
}
}
}
return iso
}
const formatLocalTimestamp = (utcValue) => {
const iso = normalizeUtcToIso(utcValue)
if (!iso) return ''
const tz = getUserTimezone()
const d = new Date(iso)
return d.toLocaleString(undefined, {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
const toLocalISOString = (date) => { const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16) const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
return localISOTime return localISOTime
} }
const manualClockOut = ref({ const manualClockOut = ref({
timestamp: toLocalISOString(new Date()), timestamp: toLocalISOString(new Date()),
notes: '', notes: ''
}) })
const today = new Date() const today = new Date()
@@ -152,15 +251,14 @@ setStartDay.setDate(today.getDate() - 60)
const filters = ref({ const filters = ref({
startDate: setStartDay.toISOString().split('T')[0], startDate: setStartDay.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0], endDate: today.toISOString().split('T')[0]
}) })
const exportLoading = ref(false); const exportLoading = ref(false)
const goBack = () => { const goBack = () => {
// Navigate back to the manager dashboard (PersonnelManagement component) window.history.back()
window.history.back(); }
};
const fetchRecords = async () => { const fetchRecords = async () => {
let url = `/api/managers/attendance-records?workerIds=${workerId}` let url = `/api/managers/attendance-records?workerIds=${workerId}`
@@ -174,21 +272,19 @@ const fetchRecords = async () => {
if (data && Array.isArray(data)) { if (data && Array.isArray(data)) {
records.value = data records.value = data
if (!workerName.value && data.length > 0) { if (!workerName.value && data.length > 0) {
// Check if worker data is cached const cachedWorkerData = workerCache.getWorkerData(workerId)
const cachedWorkerData = workerCache.getWorkerData(workerId);
if (cachedWorkerData) { if (cachedWorkerData) {
workerName.value = cachedWorkerData.full_name; workerName.value = cachedWorkerData.full_name
} else { } else {
workerName.value = data[0].full_name; workerName.value = data[0].full_name
// Cache the worker data for future use workerCache.storeWorkerData(workerId, { full_name: data[0].full_name })
workerCache.storeWorkerData(workerId, { full_name: data[0].full_name });
} }
} }
} else { } else {
records.value = [] records.value = []
} }
} catch (_err) { } catch (_err) {
console.error('Failed to fetch attendance records:',_err) console.error('Failed to fetch attendance records:', _err)
alert(_err.message) alert(_err.message)
records.value = [] records.value = []
} }
@@ -208,14 +304,14 @@ const addManualClockOut = async () => {
await apiFetch('/api/managers/add-record', { await apiFetch('/api/managers/add-record', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
workerId: workerId, workerId: workerId,
eventType: 'clock_out', eventType: 'clock_out',
timestamp: manualClockOut.value.timestamp, timestamp: manualClockOut.value.timestamp,
notes: manualClockOut.value.notes, notes: manualClockOut.value.notes
}), })
}) })
alert(t('manualClockOutSuccess')) alert(t('manualClockOutSuccess'))
@@ -223,37 +319,45 @@ const addManualClockOut = async () => {
manualClockOut.value.timestamp = toLocalISOString(new Date()) manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords() fetchRecords()
} catch (_err) { } catch (_err) {
console.error('Failed to submit manual clock-out:',_err) console.error('Failed to submit manual clock-out:', _err)
alert(t('manualClockOutError', { msg: _err.message })) alert(t('manualClockOutError', { msg: _err.message }))
} }
} }
const exportRawRecords = async () => { const exportRawRecords = async () => {
exportLoading.value = true; exportLoading.value = true
const { startDate, endDate } = filters.value; const { startDate, endDate } = filters.value
const tz = localStorage.getItem('tz') || getUserTimezone()
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(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}&tz=${encodeURIComponent(
tz
)}`,
{
headers: { headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}` Authorization: `Bearer ${sessionStorage.getItem('token')}`,
'X-User-Timezone': tz
} }
}); }
if (!response.ok) throw new Error('Network response was not ok.'); )
const blob = await response.blob(); if (!response.ok) throw new Error('Network response was not ok.')
const url = window.URL.createObjectURL(blob); const blob = await response.blob()
const a = document.createElement('a'); const url = window.URL.createObjectURL(blob)
a.href = url; const a = document.createElement('a')
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`; a.href = url
document.body.appendChild(a); a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`
a.click(); document.body.appendChild(a)
a.remove(); a.click()
window.URL.revokeObjectURL(url); a.remove()
window.URL.revokeObjectURL(url)
} catch (_err) { } catch (_err) {
alert('Failed to export records.'); alert('Failed to export records.')
} finally { } finally {
exportLoading.value = false; exportLoading.value = false
} }
}; }
onMounted(() => { onMounted(() => {
fetchRecords() fetchRecords()
+86 -16
View File
@@ -2,11 +2,9 @@
<div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen"> <div class="mobile-viewport bg-gray-100 dark:bg-gray-900 min-h-screen">
<!-- Back Button --> <!-- Back Button -->
<div class="fixed bottom-4 right-4 z-50"> <div class="fixed bottom-4 right-4 z-50">
<button <button @click="goBack"
@click="goBack"
class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500" class="bg-white dark:bg-gray-800 shadow-lg rounded-full p-3 hover:shadow-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Return to Dashboard" aria-label="Return to Dashboard">
>
<svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
@@ -17,26 +15,43 @@
<!-- Empty State --> <!-- Empty State -->
<div v-if="!clockHistory.length" class="text-center py-16 mt-8"> <div v-if="!clockHistory.length" class="text-center py-16 mt-8">
<ChartBarIcon class="w-16 h-16 text-gray-400 dark:text-gray-500 mx-auto mb-4" /> <ChartBarIcon class="w-16 h-16 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">{{ $t('noClockHistory') }}</h2> <h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-300">
<p class="text-gray-500 dark:text-gray-400 mt-2">{{ $t('clockHistoryEmptyState') }}</p> {{ $t('noClockHistory') }}
</h2>
<p class="text-gray-500 dark:text-gray-400 mt-2">
{{ $t('clockHistoryEmptyState') }}
</p>
</div> </div>
<!-- History List --> <!-- History List -->
<div v-else class="space-y-4 mt-8 mb-10"> <div v-else class="space-y-4 mt-8 mb-10">
<div v-for="event in clockHistory" :key="event.id" <div v-for="event in clockHistory" :key="event.id"
class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4"> class="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-5 flex items-center space-x-4">
<div class="w-12 h-12 rounded-full flex items-center justify-center" <div class="w-12 h-12 rounded-full flex items-center justify-center" :class="event.event_type === 'clock_in'
:class="event.event_type === 'clock_in' ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'"> ? 'bg-green-100 dark:bg-green-900/50'
<component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon" : 'bg-red-100 dark:bg-red-900/50'">
:class="['w-8 h-8', event.event_type === 'clock_in' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400']" /> <component :is="event.event_type === 'clock_in' ? ArrowDownCircleIcon : ArrowUpCircleIcon" :class="[
'w-8 h-8',
event.event_type === 'clock_in'
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
]" />
</div> </div>
<div class="flex-grow"> <div class="flex-grow">
<div class="font-bold text-lg text-gray-900 dark:text-gray-100">{{ $t(event.event_type) }}</div> <div class="font-bold text-lg text-gray-900 dark:text-gray-100">
<div class="text-sm text-gray-600 dark:text-gray-400">{{ event.qrCodeUsedName }}</div> {{ $t(event.event_type) }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{{ event.qrCodeUsedName }}
</div>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="font-medium text-gray-800 dark:text-gray-200">{{ new Date(event.timestamp).toLocaleDateString() }}</div> <div class="font-medium text-gray-800 dark:text-gray-200">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ new Date(event.timestamp).toLocaleTimeString() }}</div> {{ formatLocalDate(event.timestamp) }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ formatLocalTime(event.timestamp) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -56,6 +71,61 @@ const router = useRouter()
const clockHistory = ref([]) const clockHistory = ref([])
const userId = sessionStorage.getItem('userId') const userId = sessionStorage.getItem('userId')
const getUserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Kuala_Lumpur'
} catch {
return 'Asia/Kuala_Lumpur'
}
}
const normalizeUtcToIso = (utcValue) => {
if (!utcValue) return null
if (utcValue instanceof Date) {
return utcValue.toISOString()
}
let iso = utcValue
if (typeof iso === 'string') {
if (!iso.endsWith('Z')) {
if (iso.includes('T')) {
iso = iso + 'Z'
} else {
iso = iso.replace(' ', 'T') + 'Z'
}
}
}
return iso
}
const formatLocalDate = (utcValue) => {
const iso = normalizeUtcToIso(utcValue)
if (!iso) return ''
const tz = getUserTimezone()
const d = new Date(iso)
return d.toLocaleDateString(undefined, {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
const formatLocalTime = (utcValue) => {
const iso = normalizeUtcToIso(utcValue)
if (!iso) return ''
const tz = getUserTimezone()
const d = new Date(iso)
return d.toLocaleTimeString(undefined, {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
onMounted(async () => { onMounted(async () => {
if (!userId) { if (!userId) {
router.push('/') router.push('/')
@@ -64,7 +134,7 @@ onMounted(async () => {
try { try {
const data = await apiFetch(`/api/worker/clock-history/${userId}`) const data = await apiFetch(`/api/worker/clock-history/${userId}`)
if (data) { if (data) {
clockHistory.value = data.filter(event => event.event_type !== 'failed'); clockHistory.value = data.filter(event => event.event_type !== 'failed')
} }
} catch (error) { } catch (error) {
console.error(t('clockHistoryFetchFail'), error) console.error(t('clockHistoryFetchFail'), error)