27 Commits

Author SHA1 Message Date
Edison 5f99ca55c2 change to request format on the txt 2026-03-26 16:52:17 +08:00
Edison c98b16dbd7 Reapply "added txt file export as extra export."
This reverts commit d51c16399c.
2026-03-19 16:56:30 +08:00
Edison d51c16399c Revert "added txt file export as extra export."
This reverts commit 99f80a25d0.
2026-03-19 16:56:13 +08:00
Edison 99f80a25d0 added txt file export as extra export. 2026-03-19 16:33:22 +08:00
Edison 9f65883534 arrow detailed 2026-01-02 15:15:20 +08:00
Edison 2d7ddbb96a department filter and excel support 2026-01-02 14:51:36 +08:00
Edison 2e7de997ff add total hours and summary on excel.js 2025-12-17 08:43:19 +08:00
Edison e31416d91d add enable month and disable 2025-11-14 11:20:36 +08:00
winter.liang 193da32ca4 Merge branch 'main' of https://git.wlcent.cn/NiLai_Clock/Nilai_Clock 2025-11-11 14:00:08 +08:00
Edison f2865be33a Merge branch 'main' of https://git.wlcent.cn/Marc.ma/Nilai_Clock 2025-11-11 13:55:16 +08:00
Edison eb0c3de489 Merge branch 'main' into new_edi_branch 2025-11-11 13:52:44 +08:00
Edison 5b03d39e36 Merge branch 'main' into new_edi_branch 2025-11-11 13:45:04 +08:00
winter.liang fee699b529 fix:connection lost 2025-11-07 11:28:44 +08:00
Edison a5f6803f91 Merge branch 'main' into new_edi_branch 2025-11-06 16:19:56 +08:00
winter.liang 899b6fae93 fix:connection lost 2025-11-06 10:05:07 +08:00
winter.liang e9330b8e2d fix:timezone has no effect 2025-11-05 15:32:27 +08:00
Edison 8c04d91a18 rehydrate for delete and also allow fullName changing 2025-11-04 10:05:13 +08:00
winter.liang b577d5ad1b fix:time display 2025-11-03 18:18:24 +08:00
winter.liang 30d2e932e5 fix:time display 2025-11-03 18:05:37 +08:00
winter.liang 4ce4b21315 fix:time display 2025-11-03 17:59:20 +08:00
winter.liang 9b1eb38dd9 fix:time display 2025-11-03 17:34:03 +08:00
winter.liang 6d31e4db09 Merge remote-tracking branch 'origin/fix-timestamp'
# Conflicts:
#	backend/server.js
#	backend/workerRoutes.js
#	src/views/ManagerAttendanceRecord.vue
2025-11-03 17:09:07 +08:00
winter.liang 9bb899cc05 Merge remote-tracking branch 'origin/main' into new_edi_branch 2025-11-03 14:31:01 +08:00
Edison 1d89d47c53 frontend and backend to follow db.js strictly 2025-11-03 14:23:35 +08:00
Edison 7e37230894 calculations 2025-11-03 13:35:35 +08:00
longke df32dab9aa console log 2025-11-03 12:01:23 +08:00
Edison 7231310f93 timezone update 2025-11-03 11:31:34 +08:00
14 changed files with 1615 additions and 877 deletions
+8
View File
@@ -0,0 +1,8 @@
// backend/config/db.js
import 'dotenv/config';
export const APP_TIMEZONE =
process.env.APP_TIMEZONE || '+07:00'; // default for Nilai
//process.env.APP_TIMEZONE || 'Asia/Jakarta'; // default for Indonesia
// All dates from DB are treated as if they are in this timezone
+1071 -634
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
import mysql from 'mysql2/promise'
import { APP_TIMEZONE } from './config/db.js'
import dotenv from 'dotenv'
import path from 'path'
import { fileURLToPath } from 'url'
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
// timezone: '+08:00',
dateStrings: true,
});
const originalGetConnection = db.getConnection.bind(db);
db.getConnection = async () => {
const connection = await originalGetConnection();
// 设置时区
await connection.execute(`SET time_zone = '${APP_TIMEZONE}'`);
return connection;
};
export const getConnection = async () => {
return await db.getConnection()
}
+5 -18
View File
@@ -7,30 +7,17 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import mysql from 'mysql2/promise';
import managerRoutes from './managerRoutes.js'; import managerRoutes from './managerRoutes.js';
import workerRoutes from './workerRoutes.js'; import workerRoutes from './workerRoutes.js';
import { getConnection } from './pool.js'
async function startServer() { async function startServer() {
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }); dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
const app = express(); const app = express();
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: 'Z',
dateStrings: true
});
try { try {
const connection = await db.getConnection(); const connection = await getConnection();
console.log('Database connected successfully!'); console.log('Database connected successfully!');
connection.release(); connection.release();
} catch (error) { } catch (error) {
@@ -55,7 +42,7 @@ async function startServer() {
}, },
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'], allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning', 'X-User-Timezone'], //added X-User-Timezone for my development (Edison)
exposedHeaders: ['Content-Range', 'X-Content-Range'], exposedHeaders: ['Content-Range', 'X-Content-Range'],
}; };
@@ -78,8 +65,8 @@ async function startServer() {
app.get('/time', timeHandler); // public path app.get('/time', timeHandler); // public path
app.get('/api/time', timeHandler); // also under /api app.get('/api/time', timeHandler); // also under /api
app.use('/api/managers', managerRoutes(db)); app.use('/api/managers', managerRoutes());
app.use('/api', workerRoutes(db)); app.use('/api', workerRoutes());
const httpPort = process.env.HTTP_PORT || 3000; const httpPort = process.env.HTTP_PORT || 3000;
const httpsPort = process.env.HTTPS_PORT || 3443; const httpsPort = process.env.HTTPS_PORT || 3443;
+241 -180
View File
@@ -2,12 +2,7 @@ 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';
import { db } from './server.js'; import { getConnection } from './pool.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]);
@@ -27,7 +22,7 @@ async function isClockingEnabled(conn) {
return rows.length > 0; return rows.length > 0;
} }
export default function(db) { export default function() {
const router = express.Router(); const router = express.Router();
// Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking // Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking
@@ -36,51 +31,58 @@ export default function(db) {
const AUTO_REGISTER_NEW_DEVICES = true; const AUTO_REGISTER_NEW_DEVICES = true;
router.post('/auth/login', async (req, res) => { router.post('/auth/login', async (req, res) => {
const { username, password, deviceUuid } = req.body; const db = await getConnection();
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]); try {
if (rows.length === 0) { const { username, password, deviceUuid } = req.body;
return res.status(401).json({ message: 'Invalid credentials' }); const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
} if (rows.length === 0) {
const user = rows[0]; return res.status(401).json({ message: 'Invalid credentials' });
}
const user = rows[0];
// Check if the user's status is 'active' // Check if the user's status is 'active'
if (user.status !== 'active') { if (user.status !== 'active') {
return res.status(401).json({ message: 'Invalid credentials' }); return res.status(401).json({ message: 'Invalid credentials' });
} }
const passwordMatch = await bcrypt.compare(password, user.password_hash); const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) { if (!passwordMatch) {
return res.status(401).json({ message: 'Invalid credentials' }); return res.status(401).json({ message: 'Invalid credentials' });
} }
// Device UUID handling - controlled by configuration flags above // Device UUID handling - controlled by configuration flags above
if (DEVICE_UUID_ENABLED && user.role === 'worker') { if (DEVICE_UUID_ENABLED && user.role === 'worker') {
const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]); const [deviceRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [user.id]);
const existingDeviceUuid = deviceRows[0].device_uuid; const existingDeviceUuid = deviceRows[0].device_uuid;
if (existingDeviceUuid) { if (existingDeviceUuid) {
if (deviceUuid && deviceUuid !== existingDeviceUuid) { if (deviceUuid && deviceUuid !== existingDeviceUuid) {
return res.status(403).json({ message: 'deviceMismatch' }); return res.status(403).json({ message: 'deviceMismatch' });
} else if (!deviceUuid) { } else if (!deviceUuid) {
return res.status(403).json({ message: 'useMobileApp' }); return res.status(403).json({ message: 'useMobileApp' });
} }
} else { } else {
// User has no registered device // User has no registered device
if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) { if (deviceUuid && AUTO_REGISTER_NEW_DEVICES) {
const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db); const deviceResult = await validateDeviceForUser(user.id, deviceUuid, db);
if (!deviceResult.valid) { if (!deviceResult.valid) {
return res.status(500).json({ message: 'deviceRegistrationFailed' }); return res.status(500).json({ message: 'deviceRegistrationFailed' });
}
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
return res.status(403).json({ message: 'deviceRequired' });
} }
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
return res.status(403).json({ message: 'deviceRequired' });
} }
} }
}
// Managers can always login, workers without device_uuid can login // Managers can always login, workers without device_uuid can login
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' }); const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token }); res.json({ token });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ message: 'Server error during login' });
} finally {
db.release();
}
}); });
const authenticateJWT = (req, res, next) => { const authenticateJWT = (req, res, next) => {
@@ -91,7 +93,7 @@ export default function(db) {
if (err) { if (err) {
return res.status(403).json({ message: 'Invalid or expired token' }); return res.status(403).json({ message: 'Invalid or expired token' });
} }
req.user = { ...user, id: user.userId }; // Correctly map userId to id req.user = { ...user, id: user.userId };
next(); next();
}); });
} else { } else {
@@ -102,154 +104,181 @@ export default function(db) {
router.use(authenticateJWT); router.use(authenticateJWT);
router.post('/clock', async (req, res) => { router.post('/clock', async (req, res) => {
// NEW: borrow a connection so we can set session time_zone const db = await getConnection();
const conn = await db.getConnection(); try {
try { const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
// NEW: set session time_zone from header (defaults to KL) // 1) Kill Switch — now evaluated in the session's local day
const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur'; const clockingAllowed = await isClockingEnabled(db);
await conn.query('SET time_zone = ?', [sessionOffset(iana)]); if (!clockingAllowed) {
const note = 'Clock-in/out function is not enabled for today.';
// 1) Kill Switch — now evaluated in the session's local day await db.execute(
const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn `INSERT INTO clock_records
if (!clockingAllowed) {
const note = 'Clock-in/out function is not enabled for today.';
await conn.execute( // CHANGED: use conn
`INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`, VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
[userId, qrCodeValue, latitude, longitude, note]
);
return res.status(403).json({ message: 'error.clockingDisabled' });
}
// 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] [userId, qrCodeValue, latitude, longitude, note]
); );
return res.status(403).json({ message: 'error.noActiveGeofence' }); return res.status(403).json({ message: 'error.clockingDisabled' });
} }
const userLocation = point([longitude, latitude]); // 2) Geofence Validation
const parsedPolygons = []; if (latitude != null && longitude != null) {
let isInside = false; const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1');
for (const fence of activeFences) { if (activeFences.length === 0) {
try { const note = 'Cannot clock in: No active work area is defined.';
if (!fence.coordinates) continue; await db.execute(
const coordinates = JSON.parse(fence.coordinates); `INSERT INTO clock_records
const fencePolygon = polygon([coordinates]); (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
parsedPolygons.push(fencePolygon); VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
if (booleanPointInPolygon(userLocation, fencePolygon)) { [userId, qrCodeValue, latitude, longitude, note]
isInside = true; );
break; 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 });
} }
} catch (e) {
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
} }
}
if (!isInside) { if (!isInside) {
let minDistance = Infinity; let minDistance = Infinity;
for (const p of parsedPolygons) { for (const p of parsedPolygons) {
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' }); const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
if (distance < minDistance) minDistance = distance; if (distance < minDistance) minDistance = distance;
} }
const distanceString = minDistance.toFixed(2); const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`; const note = `Outside geofence by ${distanceString}m`;
await conn.execute( // CHANGED await db.execute(
`INSERT INTO clock_records `INSERT INTO clock_records
(worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp)
VALUES (?, "failed", ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`, VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
[userId, qrCodeValue, latitude, longitude, note] [userId, qrCodeValue, latitude, longitude, note]
); );
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` }); return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
}
} }
}
// 3) QR Code and Status Validation (switch db -> conn; logic unchanged) // 3) QR Code and Status Validation
if (qrCodeValue !== 'FORCE_CLOCK_OUT') { if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
const [qrRows] = await conn.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
if (qrRows.length === 0 || !qrRows[0].is_active) { if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'error.invalidQrCode' }); 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 (?, ?, ?, ?, ?, CURRENT_TIME())`,
[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 {
db.release();
} }
});
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 db = await getConnection();
const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]); try {
if (rows.length === 0) { const { id } = req.params;
return res.status(404).json({ message: 'Worker not found.' }); const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]);
if (rows.length === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.json(rows[0]);
} catch (error) {
console.error('Get worker error:', error);
res.status(500).json({ message: 'Server error fetching worker' });
} finally {
db.release();
} }
res.json(rows[0]);
}); });
router.get('/worker/status/:userId', async (req, res) => { router.get('/worker/status/:userId', async (req, res) => {
const { userId } = req.params; const db = await getConnection();
const [rows] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]); try {
res.json({ eventType: rows.length > 0 ? rows[0].event_type : 'clock_out' }); const { userId } = req.params;
const [rows] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
res.json({ eventType: rows.length > 0 ? rows[0].event_type : 'clock_out' });
} catch (error) {
console.error('Get worker status error:', error);
res.status(500).json({ message: 'Server error fetching worker status' });
} finally {
db.release();
}
}); });
router.get('/worker/clock-history/:userId', async (req, res) => { router.get('/worker/clock-history/:userId', async (req, res) => {
const { userId } = req.params; const db = await getConnection();
const [rows] = await db.execute(` try {
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName const { userId } = req.params;
FROM clock_records cr const [rows] = await db.execute(`
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC FROM clock_records cr
`, [userId]); LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
res.json(rows); WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
`, [userId]);
res.json(rows);
} catch (error) {
console.error('Get clock history error:', error);
res.status(500).json({ message: 'Server error fetching clock history' });
} finally {
db.release();
}
}); });
router.put('/worker/change-password', async (req, res) => { router.put('/worker/change-password', async (req, res) => {
const { userId } = req.user; const db = await getConnection();
const { currentPassword, newPassword } = req.body; try {
if (!currentPassword || !newPassword || newPassword.length < 6) { const { userId } = req.user;
return res.status(400).json({ message: 'Invalid input.' }); const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Invalid input.' });
}
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]);
const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash);
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' });
}
const newHashedPassword = await bcrypt.hash(newPassword, 10);
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
res.json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({ message: 'Server error changing password' });
} finally {
db.release();
} }
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]);
const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash);
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' });
}
const newHashedPassword = await bcrypt.hash(newPassword, 10);
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
res.json({ message: 'Password updated successfully.' });
}); });
router.post('/location/update', async (req, res) => { router.post('/location/update', async (req, res) => {
@@ -258,30 +287,62 @@ export default function(db) {
}); });
router.post('/device/register', async (req, res) => { router.post('/device/register', async (req, res) => {
const { userId, deviceUuid } = req.body; const db = await getConnection();
const result = await validateDeviceForUser(userId, deviceUuid, db); try {
res.status(result.valid ? 200 : 409).json(result); const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.status(result.valid ? 200 : 409).json(result);
} catch (error) {
console.error('Device register error:', error);
res.status(500).json({ message: 'Server error registering device' });
} finally {
db.release();
}
}); });
router.post('/device/validate', async (req, res) => { router.post('/device/validate', async (req, res) => {
const { userId, deviceUuid } = req.body; const db = await getConnection();
const result = await validateDeviceForUser(userId, deviceUuid, db); try {
res.json(result); const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.json(result);
} catch (error) {
console.error('Device validate error:', error);
res.status(500).json({ message: 'Server error validating device' });
} finally {
db.release();
}
}); });
router.get('/security/status/:userId', async (req, res) => { router.get('/security/status/:userId', async (req, res) => {
const { userId } = req.params; const db = await getConnection();
const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]); try {
const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]); const { userId } = req.params;
res.json({ const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]);
latestSecurityCheck: securityRows[0] || null, const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]);
recentAlerts: alertRows, res.json({
}); latestSecurityCheck: securityRows[0] || null,
recentAlerts: alertRows,
});
} catch (error) {
console.error('Security status error:', error);
res.status(500).json({ message: 'Server error fetching security status' });
} finally {
db.release();
}
}); });
router.get('/security/app-blacklist', async (req, res) => { router.get('/security/app-blacklist', async (req, res) => {
const [rows] = await db.execute('SELECT package_name FROM app_blacklist'); const db = await getConnection();
res.json(rows.map(row => row.package_name)); try {
const [rows] = await db.execute('SELECT package_name FROM app_blacklist');
res.json(rows.map(row => row.package_name));
} catch (error) {
console.error('App blacklist error:', error);
res.status(500).json({ message: 'Server error fetching app blacklist' });
} finally {
db.release();
}
}); });
return router; return router;
+77 -1
View File
@@ -34,7 +34,43 @@
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-4"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-4">
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">{{ $t('pendingChanges') }}</h3> <h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">
{{ $t('pendingChanges') }}
</h3>
<div class="mb-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
This will enable all dates on the current month you are on
</p>
<div class="grid grid-cols-2 gap-2">
<button
type="button"
@click="enableAllCurrentMonth"
class="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg text-sm font-medium
text-green-700 bg-green-50 hover:bg-green-100 border border-green-200
dark:text-green-200 dark:bg-green-900/20 dark:hover:bg-green-900/40 dark:border-green-800
transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Enable ({{ viewDate.toLocaleString('default', { month: 'long' }) }})
</button>
<button
type="button"
@click="disableAllCurrentMonth"
class="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-lg text-sm font-medium
text-red-700 bg-red-50 hover:bg-red-100 border border-red-200
dark:text-red-200 dark:bg-red-900/20 dark:hover:bg-red-900/40 dark:border-red-800
transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Disable ({{ viewDate.toLocaleString('default', { month: 'long' }) }})
</button>
</div>
</div>
<div v-if="!hasPendingChanges" class="text-center py-8 text-gray-500 dark:text-gray-400"> <div v-if="!hasPendingChanges" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ $t('noPendingChanges') }} {{ $t('noPendingChanges') }}
</div> </div>
@@ -175,6 +211,19 @@ const calendarGrid = computed(() => {
return grid; return grid;
}); });
const getCurrentMonthDateStrings = () => {
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const list = [];
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
list.push(dateStr);
}
return list;
};
const getDayClasses = (day) => { const getDayClasses = (day) => {
if (!day.isCurrentMonth) return 'h-20'; if (!day.isCurrentMonth) return 'h-20';
@@ -242,6 +291,33 @@ function onDayClick(day) {
: datesToEnable.value.add(dateStr); : datesToEnable.value.add(dateStr);
} }
} }
function enableAllCurrentMonth() {
const dates = getCurrentMonthDateStrings();
dates.forEach((dateStr) => {
datesToDisable.value.delete(dateStr);
if (originalEnabledDates.value.has(dateStr)) {
datesToEnable.value.delete(dateStr);
} else {
datesToEnable.value.add(dateStr);
}
});
}
function disableAllCurrentMonth() {
const dates = getCurrentMonthDateStrings();
dates.forEach((dateStr) => {
datesToEnable.value.delete(dateStr);
if (originalEnabledDates.value.has(dateStr)) {
datesToDisable.value.add(dateStr);
} else {
datesToDisable.value.delete(dateStr);
}
});
}
async function applyChanges() { async function applyChanges() {
const confirmed = await toast.showConfirm($t('confirmApplyChanges')); const confirmed = await toast.showConfirm($t('confirmApplyChanges'));
+143 -35
View File
@@ -51,32 +51,59 @@
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6"> <section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<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="flex-grow"> <div class="mb-6 flex items-end gap-4">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" <div class="flex-1 min-w-0">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByName')"
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" /> 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="shrink-0 flex flex-col gap-2">
<div class="flex items-end gap-4"> <label for="department-filter" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
<div class="flex flex-col gap-2"> $t('departmentFilter') }}</label>
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ <div class="relative">
$t('startDate') }}</label> <select
<input type="date" id="export-start-date" v-model="exportFilters.startDate" id="department-filter"
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" /> v-model="selectedDepartment"
</div> class="appearance-none border border-gray-300 dark:border-gray-600 rounded-md pl-3 pr-10 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white min-w-[180px] w-full"
<div class="flex flex-col gap-2"> >
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') <option value="">{{ $t('allDepartments') }}</option>
}}</label> <option v-for="dept in departments" :key="dept" :value="dept">
<input type="date" id="export-end-date" v-model="exportFilters.endDate" {{ dept }}
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" /> </option>
</div> </select>
<svg xmlns="http://www.w3.org/2000/svg" class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500 dark:text-gray-400 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div class="shrink-0 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>
<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" />
</div>
<div class="shrink-0 flex flex-col gap-2">
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
}}</label>
<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>
<div class="shrink-0">
<button @click="exportWorkHours" <button @click="exportWorkHours"
:disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" :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"> 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') }} {{ exportLoading ? $t('exporting') : $t('exportAll') }}
</button> </button>
</div> </div>
<div class="shrink-0">
<button @click="exportTxt"
:disabled="!exportFilters.startDate || !exportFilters.endDate || txtExportLoading"
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">
{{ txtExportLoading ? $t('exporting') : 'Export TXT' }}
</button>
</div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-[700px] w-full text-left"> <table class="min-w-[700px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
@@ -195,19 +222,37 @@
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4> <h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
<div class="mb-4 space-y-4"> <div class="mb-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('fullName') }}
</label>
<input
type="text"
v-model="editingWorker.fullName"
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>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('department') }} {{ $t('department') }}
</label> </label>
<input type="text" v-model="editingWorker.department" <input
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" /> 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"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ $t('position') }} {{ $t('position') }}
</label> </label>
<input type="text" v-model="editingWorker.position" <input
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" /> 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>
@@ -397,6 +442,7 @@ const viewRecords = (workerId) => {
workers: workers.value, workers: workers.value,
selectedWorkerIds: selectedWorkerIds.value, selectedWorkerIds: selectedWorkerIds.value,
exportFilters: exportFilters.value, exportFilters: exportFilters.value,
selectedDepartment: selectedDepartment.value,
}; };
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState)); sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
@@ -432,8 +478,11 @@ 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);
const txtExportLoading = ref(false);
const showClearDeviceConfirm = ref(false); const showClearDeviceConfirm = ref(false);
const showDeleteConfirm = ref(false); const showDeleteConfirm = ref(false);
const departments = ref([]);
const selectedDepartment = ref('');
// --- COMPUTED --- // --- COMPUTED ---
const isFormValid = computed( const isFormValid = computed(
@@ -449,6 +498,10 @@ const isAllSelected = computed(
// --- WATCHERS --- // --- WATCHERS ---
watch(searchQuery, () => fetchWorkers(1)); watch(searchQuery, () => fetchWorkers(1));
watch(selectedDepartment, () => {
currentPage.value = 1;
fetchWorkers(1);
});
watch(currentPage, (newPage) => { watch(currentPage, (newPage) => {
selectedWorkerIds.value = []; selectedWorkerIds.value = [];
jumpToPageInput.value = newPage; jumpToPageInput.value = newPage;
@@ -458,9 +511,11 @@ 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( let url = `/api/managers/workers?search=${encodeURIComponent(searchQuery.value)}&page=${page}&limit=${pageSize.value}`;
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}` if (selectedDepartment.value) {
); url += `&department=${encodeURIComponent(selectedDepartment.value)}`;
}
const data = await apiFetch(url);
workers.value = data.workers; workers.value = data.workers;
totalWorkers.value = data.totalCount; totalWorkers.value = data.totalCount;
@@ -478,6 +533,14 @@ const fetchWorkers = async (page = currentPage.value) => {
loading.value = false; loading.value = false;
} }
}; };
const fetchDepartments = async () => {
try {
const data = await apiFetch('/api/managers/departments');
departments.value = data;
} catch (_err) {
console.error('Failed to fetch departments');
}
};
const changePage = (page) => { const changePage = (page) => {
if (page > 0 && page <= totalPages.value) { if (page > 0 && page <= totalPages.value) {
@@ -568,15 +631,16 @@ const saveWorkerSettings = async () => {
} }
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 ||
originalWorker.position !== editingWorker.value.position originalWorker.position !== editingWorker.value.position ||
) { originalWorker.full_name !== editingWorker.value.fullName
detailsUpdated = true; ) {
} detailsUpdated = true;
}
if (!passwordUpdated && !detailsUpdated) { if (!passwordUpdated && !detailsUpdated) {
passwordErrorMessage.value = 'No changes to save.'; passwordErrorMessage.value = 'No changes to save.';
@@ -600,6 +664,7 @@ const saveWorkerSettings = async () => {
status: newStatus, status: newStatus,
department: editingWorker.value.department, department: editingWorker.value.department,
position: editingWorker.value.position, position: editingWorker.value.position,
fullName: editingWorker.value.fullName,
}), }),
}); });
if (passwordUpdated) { if (passwordUpdated) {
@@ -608,7 +673,6 @@ const saveWorkerSettings = async () => {
passwordSuccessMessage.value = 'Worker details updated successfully!'; passwordSuccessMessage.value = 'Worker details updated successfully!';
} }
} }
await fetchWorkers(currentPage.value); await fetchWorkers(currentPage.value);
setTimeout(() => { setTimeout(() => {
closeSettingsModal(); closeSettingsModal();
@@ -621,7 +685,11 @@ const saveWorkerSettings = async () => {
}; };
const openSettingsModal = (worker) => { const openSettingsModal = (worker) => {
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; editingWorker.value = {
...worker,
fullName: worker.full_name,
isActive: worker.status === 'active',
};
isSettingsModalVisible.value = true; isSettingsModalVisible.value = true;
}; };
@@ -670,9 +738,14 @@ const exportWorkHours = async () => {
const { startDate, endDate } = exportFilters.value; const { startDate, endDate } = exportFilters.value;
const workerIds = selectedWorkerIds.value.join(','); const workerIds = selectedWorkerIds.value.join(',');
let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
if (selectedDepartment.value) {
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
}
try { try {
const response = await fetch( const response = await fetch(
`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, exportUrl,
{ {
headers: { headers: {
Authorization: `Bearer ${sessionStorage.getItem('token')}`, Authorization: `Bearer ${sessionStorage.getItem('token')}`,
@@ -697,7 +770,41 @@ const exportWorkHours = async () => {
} }
}; };
const exportTxt = async () => {
const toast = useToast();
txtExportLoading.value = true;
const { startDate, endDate } = exportFilters.value;
const workerIds = selectedWorkerIds.value.join(',');
let exportUrl = `${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?format=txt&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
if (selectedDepartment.value) {
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
}
try {
const response = await fetch(exportUrl, {
headers: {
Authorization: `Bearer ${sessionStorage.getItem('token')}`,
'X-User-Timezone': getUserTimezone(),
},
});
if (!response.ok) throw new Error('Network response was not ok.');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `attendance_${startDate}_to_${endDate}.txt`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_err) {
toast.showToast('Export TXT failed.', 'error');
} finally {
txtExportLoading.value = false;
}
};
onMounted(() => { onMounted(() => {
fetchDepartments();
const savedSearchState = sessionStorage.getItem('personnelSearchState'); const savedSearchState = sessionStorage.getItem('personnelSearchState');
if (savedSearchState) { if (savedSearchState) {
try { try {
@@ -709,6 +816,7 @@ 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: '' };
selectedDepartment.value = searchState.selectedDepartment || '';
sessionStorage.removeItem('personnelSearchState'); sessionStorage.removeItem('personnelSearchState');
} catch (_e) { } catch (_e) {
fetchWorkers(); fetchWorkers();
+1 -1
View File
@@ -124,7 +124,7 @@
<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"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
{{ formatLocalTimestamp(detail.timestamp) }} {{ detail.timestamp }}
</td> </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 <span
+5
View File
@@ -115,6 +115,7 @@
"chooseTag": "-- Choose a tag --", "chooseTag": "-- Choose a tag --",
"addByTag": "Add by Tag", "addByTag": "Add by Tag",
"selectedForReport": "Selected for Report ({count})", "selectedForReport": "Selected for Report ({count})",
"all": "All",
"allWorkersSelected": "All Workers ({count}) Selected", "allWorkersSelected": "All Workers ({count}) Selected",
"noWorkersSelected": "No workers selected.", "noWorkersSelected": "No workers selected.",
"reportSettings": "2. Report Settings", "reportSettings": "2. Report Settings",
@@ -139,6 +140,9 @@
"exportAll": "Export All", "exportAll": "Export All",
"export": "Export", "export": "Export",
"filterByDepartment": "Filter by Department",
"departmentFilter": "Departments",
"allDepartments": "All Departments",
"addNewUser": "Add New User", "addNewUser": "Add New User",
"fullName": "Full Name", "fullName": "Full Name",
"department": "Department", "department": "Department",
@@ -159,6 +163,7 @@
"tags": "Tags", "tags": "Tags",
"workerRoster": "Employee List", "workerRoster": "Employee List",
"searchByNameOrUsername": "Search by name/username", "searchByNameOrUsername": "Search by name/username",
"searchByName": "Search by Name",
"searchByNameOrDepartment": "Search by name/department", "searchByNameOrDepartment": "Search by name/department",
"filterByTag": "Filter by tag", "filterByTag": "Filter by tag",
"clearFilter": "Clear filter", "clearFilter": "Clear filter",
+5
View File
@@ -116,6 +116,7 @@
"chooseTag": "-- Pilih tag --", "chooseTag": "-- Pilih tag --",
"addByTag": "Tambah melalui Tag", "addByTag": "Tambah melalui Tag",
"selectedForReport": "Dipilih untuk Laporan ({count})", "selectedForReport": "Dipilih untuk Laporan ({count})",
"all": "Semua",
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih", "allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
"noWorkersSelected": "Tiada pekerja dipilih.", "noWorkersSelected": "Tiada pekerja dipilih.",
"reportSettings": "2. Tetapan Laporan", "reportSettings": "2. Tetapan Laporan",
@@ -139,6 +140,9 @@
"reportGenerationError": "Ralat semasa menjana laporan.", "reportGenerationError": "Ralat semasa menjana laporan.",
"exportAll": "Eksport Semua", "exportAll": "Eksport Semua",
"export": "Eksport", "export": "Eksport",
"filterByDepartment": "Tapis mengikut Jabatan",
"departmentFilter": "Jabatan:",
"allDepartments": "Semua Jabatan",
"addNewUser": "Tambah Pengguna Baru", "addNewUser": "Tambah Pengguna Baru",
"fullName": "Nama Penuh", "fullName": "Nama Penuh",
"department": "Jabatan", "department": "Jabatan",
@@ -158,6 +162,7 @@
"createTag": "Cipta Tag", "createTag": "Cipta Tag",
"tags": "Tag", "tags": "Tag",
"workerRoster": "Deftar Pekerja", "workerRoster": "Deftar Pekerja",
"searchByName": "Cari mengikut Nama",
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna", "searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
"searchByNameOrDepartment": " Cari nama atau jabatan", "searchByNameOrDepartment": " Cari nama atau jabatan",
"filterByTag": "Tapis mengikut tag", "filterByTag": "Tapis mengikut tag",
+4 -2
View File
@@ -97,7 +97,8 @@
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --", "chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
"addByTag": "டேக் மூலம் சேர்க்கவும்", "addByTag": "டேக் மூலம் சேர்க்கவும்",
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})", "selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்", "all": "அனைத்தும்",
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.", "noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
"reportSettings": "2. அறிக்கை அமைப்புகள்", "reportSettings": "2. அறிக்கை அமைப்புகள்",
"setting": "அமைப்பு", "setting": "அமைப்பு",
@@ -133,7 +134,8 @@
"createTag": "டேக் உருவாக்கவும்", "createTag": "டேக் உருவாக்கவும்",
"tags": "டேக்குகள்", "tags": "டேக்குகள்",
"workerRoster": "பணியாளர் பட்டியல்", "workerRoster": "பணியாளர் பட்டியல்",
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்", "searchByName": "பெயரால் தேட",
"searchByNameOrUsername": "பெயர் அல்லது பயனர்பெயரால் தேடு",
"filterByTag": "டேக் மூலம் வடிகட்டவும்", "filterByTag": "டேக் மூலம் வடிகட்டவும்",
"clearFilter": "வடிகட்டியைத் துடைக்கவும்", "clearFilter": "வடிகட்டியைத் துடைக்கவும்",
"dateJoined": "சேர்ந்த தேதி", "dateJoined": "சேர்ந்த தேதி",
+2 -3
View File
@@ -145,7 +145,7 @@
</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">
{{ formatLocalTimestamp(record.timestamp) }} {{ record.timestamp }}
</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">
{{ record.qrCodeUsedName }} {{ record.qrCodeUsedName }}
@@ -235,8 +235,7 @@ const formatLocalTimestamp = (utcValue) => {
} }
const toLocalISOString = (date) => { const toLocalISOString = (date) => {
const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms const localISOTime = new Date(date).toISOString().slice(0, 16)
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
return localISOTime return localISOTime
} }
+17 -1
View File
@@ -186,13 +186,29 @@ const fetchCurrentStatus = async () => {
} }
} }
const getTimeContext = () => {
const tzName = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
const offsetMin = -new Date().getTimezoneOffset() // east of UTC => positive
return { device_epoch_ms: Date.now(), tz_name: tzName, offset_min: offsetMin }
}
const sendClockEvent = async (qrCodeValue, latitude, longitude) => { const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in' const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try { try {
const payload = {
userId,
eventType,
qrCodeValue,
latitude,
longitude,
...getTimeContext() // new: device_epoch_ms, tz_name, offset_min
}
await apiFetch('/api/clock', { await apiFetch('/api/clock', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, eventType, qrCodeValue, latitude, longitude }), body: JSON.stringify(payload),
}); });
const newClockStatus = !isClockedIn.value const newClockStatus = !isClockedIn.value
isClockedIn.value = newClockStatus isClockedIn.value = newClockStatus
triggerOverlay(t(newClockStatus ? 'successClockIn' : 'successClockOut'), 'success'); triggerOverlay(t(newClockStatus ? 'successClockIn' : 'successClockOut'), 'success');
+2 -2
View File
@@ -47,10 +47,10 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="font-medium text-gray-800 dark:text-gray-200"> <div class="font-medium text-gray-800 dark:text-gray-200">
{{ formatLocalDate(event.timestamp) }} {{ event.timestamp }}
</div> </div>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div class="text-sm text-gray-500 dark:text-gray-400">
{{ formatLocalTime(event.timestamp) }} {{ event.timestamp }}
</div> </div>
</div> </div>
</div> </div>