Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f99ca55c2 | |||
| c98b16dbd7 | |||
| d51c16399c | |||
| 99f80a25d0 | |||
| 9f65883534 | |||
| 2d7ddbb96a | |||
| 2e7de997ff | |||
| e31416d91d | |||
| 193da32ca4 | |||
| f2865be33a | |||
| eb0c3de489 | |||
| 5b03d39e36 | |||
| fee699b529 | |||
| a5f6803f91 | |||
| 899b6fae93 | |||
| e9330b8e2d | |||
| 8c04d91a18 | |||
| b577d5ad1b | |||
| 30d2e932e5 | |||
| 4ce4b21315 | |||
| 9b1eb38dd9 | |||
| 6d31e4db09 | |||
| 9bb899cc05 | |||
| 1d89d47c53 | |||
| 7e37230894 | |||
| df32dab9aa | |||
| 7231310f93 |
@@ -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
|
||||||
+950
-513
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||||
|
|||||||
+93
-32
@@ -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,6 +31,8 @@ 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 db = await getConnection();
|
||||||
|
try {
|
||||||
const { username, password, deviceUuid } = req.body;
|
const { username, password, deviceUuid } = req.body;
|
||||||
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
|
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -71,7 +68,6 @@ export default function(db) {
|
|||||||
if (!deviceResult.valid) {
|
if (!deviceResult.valid) {
|
||||||
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||||
}
|
}
|
||||||
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
|
|
||||||
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||||
return res.status(403).json({ message: 'deviceRequired' });
|
return res.status(403).json({ message: 'deviceRequired' });
|
||||||
}
|
}
|
||||||
@@ -81,6 +77,12 @@ export default function(db) {
|
|||||||
// 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,38 +104,33 @@ 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)
|
|
||||||
const iana = req.headers['x-user-timezone'] || 'Asia/Kuala_Lumpur';
|
|
||||||
await conn.query('SET time_zone = ?', [sessionOffset(iana)]);
|
|
||||||
|
|
||||||
// 1) Kill Switch — now evaluated in the session's local day
|
// 1) Kill Switch — now evaluated in the session's local day
|
||||||
const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn
|
const clockingAllowed = await isClockingEnabled(db);
|
||||||
if (!clockingAllowed) {
|
if (!clockingAllowed) {
|
||||||
const note = 'Clock-in/out function is not enabled for today.';
|
const note = 'Clock-in/out function is not enabled for today.';
|
||||||
await conn.execute( // CHANGED: use conn
|
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.clockingDisabled' });
|
return res.status(403).json({ message: 'error.clockingDisabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Geofence Validation (unchanged logic, just switch db -> conn)
|
// 2) Geofence Validation
|
||||||
if (latitude != null && longitude != null) {
|
if (latitude != null && longitude != null) {
|
||||||
const [activeFences] = await conn.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED
|
const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1');
|
||||||
|
|
||||||
if (activeFences.length === 0) {
|
if (activeFences.length === 0) {
|
||||||
const note = 'Cannot clock in: No active work area is defined.';
|
const note = 'Cannot clock in: No active work area is defined.';
|
||||||
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.noActiveGeofence' });
|
return res.status(403).json({ message: 'error.noActiveGeofence' });
|
||||||
@@ -166,25 +163,25 @@ export default function(db) {
|
|||||||
}
|
}
|
||||||
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 conn.execute( // CHANGED
|
const [lastEvent] = await db.execute(
|
||||||
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
||||||
[userId]
|
[userId]
|
||||||
);
|
);
|
||||||
@@ -193,11 +190,11 @@ export default function(db) {
|
|||||||
return res.status(400).json({ message: errorKey });
|
return res.status(400).json({ message: errorKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Record Successful Event — store UTC via SQL conversion (no JS date math)
|
// 4) Record Successful Event
|
||||||
await conn.execute(
|
await db.execute(
|
||||||
`INSERT INTO clock_records
|
`INSERT INTO clock_records
|
||||||
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
|
(worker_id, event_type, qr_code_id, latitude, longitude, timestamp)
|
||||||
VALUES (?, ?, ?, ?, ?, CONVERT_TZ(NOW(), @@session.time_zone, '+00:00'))`,
|
VALUES (?, ?, ?, ?, ?, CURRENT_TIME())`,
|
||||||
[userId, eventType, qrCodeValue, latitude, longitude]
|
[userId, eventType, qrCodeValue, latitude, longitude]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,26 +203,44 @@ export default function(db) {
|
|||||||
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
|
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
|
||||||
res.status(500).json({ message: 'error.criticalServer' });
|
res.status(500).json({ message: 'error.criticalServer' });
|
||||||
} finally {
|
} finally {
|
||||||
if (conn) conn.release();
|
db.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/workers/:id', async (req, res) => {
|
router.get('/workers/:id', async (req, res) => {
|
||||||
|
const db = await getConnection();
|
||||||
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]);
|
const [rows] = await db.execute("SELECT full_name FROM workers WHERE id = ? AND role = 'worker'", [id]);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return res.status(404).json({ message: 'Worker not found.' });
|
return res.status(404).json({ message: 'Worker not found.' });
|
||||||
}
|
}
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get worker error:', error);
|
||||||
|
res.status(500).json({ message: 'Server error fetching worker' });
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/worker/status/:userId', async (req, res) => {
|
router.get('/worker/status/:userId', async (req, res) => {
|
||||||
|
const db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId } = req.params;
|
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]);
|
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' });
|
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 db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const [rows] = await db.execute(`
|
const [rows] = await db.execute(`
|
||||||
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
|
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
|
||||||
@@ -234,9 +249,17 @@ export default function(db) {
|
|||||||
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
|
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
|
||||||
`, [userId]);
|
`, [userId]);
|
||||||
res.json(rows);
|
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 db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId } = req.user;
|
const { userId } = req.user;
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
||||||
@@ -250,6 +273,12 @@ export default function(db) {
|
|||||||
const newHashedPassword = await bcrypt.hash(newPassword, 10);
|
const newHashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
|
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
|
||||||
res.json({ message: 'Password updated successfully.' });
|
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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/location/update', async (req, res) => {
|
router.post('/location/update', async (req, res) => {
|
||||||
@@ -258,18 +287,36 @@ export default function(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/device/register', async (req, res) => {
|
router.post('/device/register', async (req, res) => {
|
||||||
|
const db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId, deviceUuid } = req.body;
|
const { userId, deviceUuid } = req.body;
|
||||||
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
||||||
res.status(result.valid ? 200 : 409).json(result);
|
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 db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId, deviceUuid } = req.body;
|
const { userId, deviceUuid } = req.body;
|
||||||
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
const result = await validateDeviceForUser(userId, deviceUuid, db);
|
||||||
res.json(result);
|
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 db = await getConnection();
|
||||||
|
try {
|
||||||
const { userId } = req.params;
|
const { userId } = req.params;
|
||||||
const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]);
|
const [securityRows] = await db.execute('SELECT * FROM security_checks WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [userId]);
|
||||||
const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]);
|
const [alertRows] = await db.execute('SELECT * FROM security_alerts WHERE user_id = ? AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', [userId]);
|
||||||
@@ -277,11 +324,25 @@ export default function(db) {
|
|||||||
latestSecurityCheck: securityRows[0] || null,
|
latestSecurityCheck: securityRows[0] || null,
|
||||||
recentAlerts: alertRows,
|
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 db = await getConnection();
|
||||||
|
try {
|
||||||
const [rows] = await db.execute('SELECT package_name FROM app_blacklist');
|
const [rows] = await db.execute('SELECT package_name FROM app_blacklist');
|
||||||
res.json(rows.map(row => row.package_name));
|
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;
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div class="relative">
|
||||||
|
<select
|
||||||
|
id="department-filter"
|
||||||
|
v-model="selectedDepartment"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="">{{ $t('allDepartments') }}</option>
|
||||||
|
<option v-for="dept in departments" :key="dept" :value="dept">
|
||||||
|
{{ dept }}
|
||||||
|
</option>
|
||||||
|
</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">{{
|
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||||
$t('startDate') }}</label>
|
$t('startDate') }}</label>
|
||||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate"
|
<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" />
|
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>
|
||||||
<div class="flex flex-col gap-2">
|
<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 for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
||||||
}}</label>
|
}}</label>
|
||||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate"
|
<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" />
|
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>
|
||||||
|
<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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+3
-1
@@ -97,6 +97,7 @@
|
|||||||
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
||||||
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
||||||
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
||||||
|
"all": "அனைத்தும்",
|
||||||
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
||||||
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
||||||
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
||||||
@@ -133,7 +134,8 @@
|
|||||||
"createTag": "டேக் உருவாக்கவும்",
|
"createTag": "டேக் உருவாக்கவும்",
|
||||||
"tags": "டேக்குகள்",
|
"tags": "டேக்குகள்",
|
||||||
"workerRoster": "பணியாளர் பட்டியல்",
|
"workerRoster": "பணியாளர் பட்டியல்",
|
||||||
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்",
|
"searchByName": "பெயரால் தேடு",
|
||||||
|
"searchByNameOrUsername": "பெயர் அல்லது பயனர்பெயரால் தேடு",
|
||||||
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
||||||
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
||||||
"dateJoined": "சேர்ந்த தேதி",
|
"dateJoined": "சேர்ந்த தேதி",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user