Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c58565af77 | |||
| 66d29fa4b6 | |||
| 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
|
||||
+943
-504
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: 50,
|
||||
queueLimit: 100,
|
||||
// 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 { fileURLToPath } from 'url';
|
||||
import dotenv from 'dotenv';
|
||||
import mysql from 'mysql2/promise';
|
||||
import managerRoutes from './managerRoutes.js';
|
||||
import workerRoutes from './workerRoutes.js';
|
||||
import { getConnection } from './pool.js'
|
||||
|
||||
async function startServer() {
|
||||
dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
|
||||
|
||||
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 {
|
||||
const connection = await db.getConnection();
|
||||
const connection = await getConnection();
|
||||
console.log('Database connected successfully!');
|
||||
connection.release();
|
||||
} catch (error) {
|
||||
@@ -55,7 +42,7 @@ async function startServer() {
|
||||
},
|
||||
credentials: true,
|
||||
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'],
|
||||
};
|
||||
|
||||
@@ -78,8 +65,8 @@ async function startServer() {
|
||||
app.get('/time', timeHandler); // public path
|
||||
app.get('/api/time', timeHandler); // also under /api
|
||||
|
||||
app.use('/api/managers', managerRoutes(db));
|
||||
app.use('/api', workerRoutes(db));
|
||||
app.use('/api/managers', managerRoutes());
|
||||
app.use('/api', workerRoutes());
|
||||
|
||||
const httpPort = process.env.HTTP_PORT || 3000;
|
||||
const httpsPort = process.env.HTTPS_PORT || 3443;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getConnection } from './pool.js'
|
||||
// import mysql from 'mysql2/promise'
|
||||
//
|
||||
//
|
||||
//
|
||||
// export const db = mysql.createPool({
|
||||
// host: '47.254.195.2',
|
||||
// user: 'nilai_clock_indo',
|
||||
// password: '5jHy8ZsfeEjPAhYS',
|
||||
// database: 'nilai_clock_indo',
|
||||
// port: 3306,
|
||||
// waitForConnections: true,
|
||||
// connectionLimit: 10,
|
||||
// queueLimit: 0,
|
||||
// dateStrings: true,
|
||||
// // 不在这里设置timezone,因为我们用查询设置
|
||||
// });
|
||||
|
||||
const conn = await getConnection()
|
||||
|
||||
const [rows] = await conn.execute('select * from clock_records where id= 6654');
|
||||
console.log(rows);
|
||||
+92
-31
@@ -2,12 +2,7 @@ import express from 'express';
|
||||
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from './server.js';
|
||||
import { withTzSession } from './middleware/withTzSession.js';
|
||||
|
||||
|
||||
// Map IANA (no DST for KL/Jakarta)
|
||||
const sessionOffset = (iana) => (iana === 'Asia/Jakarta' ? '+07:00' : '+08:00');
|
||||
import { getConnection } from './pool.js';
|
||||
|
||||
async function validateDeviceForUser(userId, deviceUuid, db) {
|
||||
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;
|
||||
}
|
||||
|
||||
export default function(db) {
|
||||
export default function() {
|
||||
const router = express.Router();
|
||||
|
||||
// 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;
|
||||
|
||||
router.post('/auth/login', async (req, res) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { username, password, deviceUuid } = req.body;
|
||||
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
|
||||
if (rows.length === 0) {
|
||||
@@ -71,7 +68,6 @@ export default function(db) {
|
||||
if (!deviceResult.valid) {
|
||||
return res.status(500).json({ message: 'deviceRegistrationFailed' });
|
||||
}
|
||||
// console.log(`Device UUID registered for worker ${user.id}: ${deviceUuid}`);
|
||||
} else if (!deviceUuid && REQUIRE_DEVICE_FOR_WORKERS) {
|
||||
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
|
||||
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
|
||||
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) => {
|
||||
@@ -91,7 +93,7 @@ export default function(db) {
|
||||
if (err) {
|
||||
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();
|
||||
});
|
||||
} else {
|
||||
@@ -102,38 +104,33 @@ export default function(db) {
|
||||
router.use(authenticateJWT);
|
||||
|
||||
router.post('/clock', async (req, res) => {
|
||||
// NEW: borrow a connection so we can set session time_zone
|
||||
const conn = await db.getConnection();
|
||||
const db = await getConnection();
|
||||
try {
|
||||
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
|
||||
const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn
|
||||
const clockingAllowed = await isClockingEnabled(db);
|
||||
if (!clockingAllowed) {
|
||||
const note = 'Clock-in/out function is not enabled for today.';
|
||||
await conn.execute( // CHANGED: use conn
|
||||
await db.execute(
|
||||
`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'))`,
|
||||
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)
|
||||
// 2) Geofence Validation
|
||||
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) {
|
||||
const note = 'Cannot clock in: No active work area is defined.';
|
||||
await conn.execute( // CHANGED
|
||||
await db.execute(
|
||||
`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'))`,
|
||||
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, qrCodeValue, latitude, longitude, note]
|
||||
);
|
||||
return res.status(403).json({ message: 'error.noActiveGeofence' });
|
||||
@@ -166,25 +163,25 @@ export default function(db) {
|
||||
}
|
||||
const distanceString = minDistance.toFixed(2);
|
||||
const note = `Outside geofence by ${distanceString}m`;
|
||||
await conn.execute( // CHANGED
|
||||
await db.execute(
|
||||
`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'))`,
|
||||
VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`,
|
||||
[userId, qrCodeValue, latitude, longitude, note]
|
||||
);
|
||||
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) QR Code and Status Validation (switch db -> conn; logic unchanged)
|
||||
// 3) QR Code and Status Validation
|
||||
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) {
|
||||
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',
|
||||
[userId]
|
||||
);
|
||||
@@ -193,11 +190,11 @@ export default function(db) {
|
||||
return res.status(400).json({ message: errorKey });
|
||||
}
|
||||
|
||||
// 4) Record Successful Event — store UTC via SQL conversion (no JS date math)
|
||||
await conn.execute(
|
||||
// 4) Record Successful Event
|
||||
await db.execute(
|
||||
`INSERT INTO clock_records
|
||||
(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]
|
||||
);
|
||||
|
||||
@@ -206,26 +203,44 @@ export default function(db) {
|
||||
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
|
||||
res.status(500).json({ message: 'error.criticalServer' });
|
||||
} finally {
|
||||
if (conn) conn.release();
|
||||
db.release();
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/workers/:id', async (req, res) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/worker/status/:userId', async (req, res) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
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) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const [rows] = await db.execute(`
|
||||
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
|
||||
`, [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) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
const { userId } = req.user;
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
if (!currentPassword || !newPassword || newPassword.length < 6) {
|
||||
@@ -250,6 +273,12 @@ export default function(db) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/location/update', async (req, res) => {
|
||||
@@ -258,18 +287,36 @@ export default function(db) {
|
||||
});
|
||||
|
||||
router.post('/device/register', async (req, res) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
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) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
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) => {
|
||||
const db = await getConnection();
|
||||
try {
|
||||
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 [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,
|
||||
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) => {
|
||||
const db = await getConnection();
|
||||
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;
|
||||
|
||||
@@ -34,7 +34,43 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{{ $t('noPendingChanges') }}
|
||||
</div>
|
||||
@@ -175,6 +211,19 @@ const calendarGrid = computed(() => {
|
||||
|
||||
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) => {
|
||||
if (!day.isCurrentMonth) return 'h-20';
|
||||
@@ -242,6 +291,33 @@ function onDayClick(day) {
|
||||
: 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() {
|
||||
const confirmed = await toast.showConfirm($t('confirmApplyChanges'));
|
||||
|
||||
@@ -51,32 +51,59 @@
|
||||
|
||||
<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>
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
|
||||
<div class="flex-grow">
|
||||
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')"
|
||||
|
||||
<div class="mb-6 flex items-end gap-4">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<label for="department-filter" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$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">{{
|
||||
$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="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>
|
||||
<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"
|
||||
:disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading"
|
||||
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<div 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 class="overflow-x-auto">
|
||||
<table class="min-w-[700px] w-full text-left">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ $t('department') }}
|
||||
</label>
|
||||
<input type="text" v-model="editingWorker.department"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="editingWorker.department"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ $t('position') }}
|
||||
</label>
|
||||
<input type="text" v-model="editingWorker.position"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
<input
|
||||
type="text"
|
||||
v-model="editingWorker.position"
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +442,7 @@ const viewRecords = (workerId) => {
|
||||
workers: workers.value,
|
||||
selectedWorkerIds: selectedWorkerIds.value,
|
||||
exportFilters: exportFilters.value,
|
||||
selectedDepartment: selectedDepartment.value,
|
||||
};
|
||||
sessionStorage.setItem('personnelSearchState', JSON.stringify(searchState));
|
||||
|
||||
@@ -432,8 +478,11 @@ const confirmMessage = ref('');
|
||||
const isConfirmModalVisible = ref(false);
|
||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||
const exportLoading = ref(false);
|
||||
const txtExportLoading = ref(false);
|
||||
const showClearDeviceConfirm = ref(false);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const departments = ref([]);
|
||||
const selectedDepartment = ref('');
|
||||
|
||||
// --- COMPUTED ---
|
||||
const isFormValid = computed(
|
||||
@@ -449,6 +498,10 @@ const isAllSelected = computed(
|
||||
|
||||
// --- WATCHERS ---
|
||||
watch(searchQuery, () => fetchWorkers(1));
|
||||
watch(selectedDepartment, () => {
|
||||
currentPage.value = 1;
|
||||
fetchWorkers(1);
|
||||
});
|
||||
watch(currentPage, (newPage) => {
|
||||
selectedWorkerIds.value = [];
|
||||
jumpToPageInput.value = newPage;
|
||||
@@ -458,9 +511,11 @@ watch(currentPage, (newPage) => {
|
||||
const fetchWorkers = async (page = currentPage.value) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await apiFetch(
|
||||
`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`
|
||||
);
|
||||
let url = `/api/managers/workers?search=${encodeURIComponent(searchQuery.value)}&page=${page}&limit=${pageSize.value}`;
|
||||
if (selectedDepartment.value) {
|
||||
url += `&department=${encodeURIComponent(selectedDepartment.value)}`;
|
||||
}
|
||||
const data = await apiFetch(url);
|
||||
workers.value = data.workers;
|
||||
totalWorkers.value = data.totalCount;
|
||||
|
||||
@@ -478,6 +533,14 @@ const fetchWorkers = async (page = currentPage.value) => {
|
||||
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) => {
|
||||
if (page > 0 && page <= totalPages.value) {
|
||||
@@ -573,7 +636,8 @@ const saveWorkerSettings = async () => {
|
||||
if (
|
||||
originalWorker.status !== newStatus ||
|
||||
originalWorker.department !== editingWorker.value.department ||
|
||||
originalWorker.position !== editingWorker.value.position
|
||||
originalWorker.position !== editingWorker.value.position ||
|
||||
originalWorker.full_name !== editingWorker.value.fullName
|
||||
) {
|
||||
detailsUpdated = true;
|
||||
}
|
||||
@@ -600,6 +664,7 @@ const saveWorkerSettings = async () => {
|
||||
status: newStatus,
|
||||
department: editingWorker.value.department,
|
||||
position: editingWorker.value.position,
|
||||
fullName: editingWorker.value.fullName,
|
||||
}),
|
||||
});
|
||||
if (passwordUpdated) {
|
||||
@@ -608,7 +673,6 @@ const saveWorkerSettings = async () => {
|
||||
passwordSuccessMessage.value = 'Worker details updated successfully!';
|
||||
}
|
||||
}
|
||||
|
||||
await fetchWorkers(currentPage.value);
|
||||
setTimeout(() => {
|
||||
closeSettingsModal();
|
||||
@@ -621,7 +685,11 @@ const saveWorkerSettings = async () => {
|
||||
};
|
||||
|
||||
const openSettingsModal = (worker) => {
|
||||
editingWorker.value = { ...worker, isActive: worker.status === 'active' };
|
||||
editingWorker.value = {
|
||||
...worker,
|
||||
fullName: worker.full_name,
|
||||
isActive: worker.status === 'active',
|
||||
};
|
||||
isSettingsModalVisible.value = true;
|
||||
};
|
||||
|
||||
@@ -670,9 +738,14 @@ const exportWorkHours = async () => {
|
||||
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=xlsx&startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`;
|
||||
if (selectedDepartment.value) {
|
||||
exportUrl += `&department=${encodeURIComponent(selectedDepartment.value)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
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: {
|
||||
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(() => {
|
||||
fetchDepartments();
|
||||
const savedSearchState = sessionStorage.getItem('personnelSearchState');
|
||||
if (savedSearchState) {
|
||||
try {
|
||||
@@ -709,6 +816,7 @@ onMounted(() => {
|
||||
workers.value = searchState.workers || [];
|
||||
selectedWorkerIds.value = searchState.selectedWorkerIds || [];
|
||||
exportFilters.value = searchState.exportFilters || { startDate: '', endDate: '' };
|
||||
selectedDepartment.value = searchState.selectedDepartment || '';
|
||||
sessionStorage.removeItem('personnelSearchState');
|
||||
} catch (_e) {
|
||||
fetchWorkers();
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<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">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
{{ formatLocalTimestamp(detail.timestamp) }}
|
||||
{{ detail.timestamp }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
<span
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"chooseTag": "-- Choose a tag --",
|
||||
"addByTag": "Add by Tag",
|
||||
"selectedForReport": "Selected for Report ({count})",
|
||||
"all": "All",
|
||||
"allWorkersSelected": "All Workers ({count}) Selected",
|
||||
"noWorkersSelected": "No workers selected.",
|
||||
"reportSettings": "2. Report Settings",
|
||||
@@ -139,6 +140,9 @@
|
||||
"exportAll": "Export All",
|
||||
"export": "Export",
|
||||
|
||||
"filterByDepartment": "Filter by Department",
|
||||
"departmentFilter": "Departments",
|
||||
"allDepartments": "All Departments",
|
||||
"addNewUser": "Add New User",
|
||||
"fullName": "Full Name",
|
||||
"department": "Department",
|
||||
@@ -159,6 +163,7 @@
|
||||
"tags": "Tags",
|
||||
"workerRoster": "Employee List",
|
||||
"searchByNameOrUsername": "Search by name/username",
|
||||
"searchByName": "Search by Name",
|
||||
"searchByNameOrDepartment": "Search by name/department",
|
||||
"filterByTag": "Filter by tag",
|
||||
"clearFilter": "Clear filter",
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
"chooseTag": "-- Pilih tag --",
|
||||
"addByTag": "Tambah melalui Tag",
|
||||
"selectedForReport": "Dipilih untuk Laporan ({count})",
|
||||
"all": "Semua",
|
||||
"allWorkersSelected": "Semua Pekerja ({count}) Dipilih",
|
||||
"noWorkersSelected": "Tiada pekerja dipilih.",
|
||||
"reportSettings": "2. Tetapan Laporan",
|
||||
@@ -139,6 +140,9 @@
|
||||
"reportGenerationError": "Ralat semasa menjana laporan.",
|
||||
"exportAll": "Eksport Semua",
|
||||
"export": "Eksport",
|
||||
"filterByDepartment": "Tapis mengikut Jabatan",
|
||||
"departmentFilter": "Jabatan:",
|
||||
"allDepartments": "Semua Jabatan",
|
||||
"addNewUser": "Tambah Pengguna Baru",
|
||||
"fullName": "Nama Penuh",
|
||||
"department": "Jabatan",
|
||||
@@ -158,6 +162,7 @@
|
||||
"createTag": "Cipta Tag",
|
||||
"tags": "Tag",
|
||||
"workerRoster": "Deftar Pekerja",
|
||||
"searchByName": "Cari mengikut Nama",
|
||||
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
||||
"searchByNameOrDepartment": " Cari nama atau jabatan",
|
||||
"filterByTag": "Tapis mengikut tag",
|
||||
|
||||
+3
-1
@@ -97,6 +97,7 @@
|
||||
"chooseTag": "-- ஒரு டேக்கைத் தேர்ந்தெடுக்கவும் --",
|
||||
"addByTag": "டேக் மூலம் சேர்க்கவும்",
|
||||
"selectedForReport": "அறிக்கைக்காக தேர்ந்தெடுக்கப்பட்டவை ({count})",
|
||||
"all": "அனைத்தும்",
|
||||
"allWorkersSelected": "அனைத்து பணியாளர்கள் ({count}) தேர்ந்தெடுக்கப்பட்டனர்",
|
||||
"noWorkersSelected": "பணியாளர்கள் எதுவும் தேர்ந்தெடுக்கப்படவில்லை.",
|
||||
"reportSettings": "2. அறிக்கை அமைப்புகள்",
|
||||
@@ -133,7 +134,8 @@
|
||||
"createTag": "டேக் உருவாக்கவும்",
|
||||
"tags": "டேக்குகள்",
|
||||
"workerRoster": "பணியாளர் பட்டியல்",
|
||||
"searchByNameOrUsername": "பெயர் அல்லது பயனர் பெயர் மூலம் தேடவும்",
|
||||
"searchByName": "பெயரால் தேடு",
|
||||
"searchByNameOrUsername": "பெயர் அல்லது பயனர்பெயரால் தேடு",
|
||||
"filterByTag": "டேக் மூலம் வடிகட்டவும்",
|
||||
"clearFilter": "வடிகட்டியைத் துடைக்கவும்",
|
||||
"dateJoined": "சேர்ந்த தேதி",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ formatLocalTimestamp(record.timestamp) }}
|
||||
{{ record.timestamp }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ record.qrCodeUsedName }}
|
||||
@@ -235,8 +235,7 @@ const formatLocalTimestamp = (utcValue) => {
|
||||
}
|
||||
|
||||
const toLocalISOString = (date) => {
|
||||
const tzoffset = new Date().getTimezoneOffset() * 60000 // offset in ms
|
||||
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
|
||||
const localISOTime = new Date(date).toISOString().slice(0, 16)
|
||||
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 eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
|
||||
try {
|
||||
const payload = {
|
||||
userId,
|
||||
eventType,
|
||||
qrCodeValue,
|
||||
latitude,
|
||||
longitude,
|
||||
...getTimeContext() // new: device_epoch_ms, tz_name, offset_min
|
||||
}
|
||||
|
||||
await apiFetch('/api/clock', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, eventType, qrCodeValue, latitude, longitude }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const newClockStatus = !isClockedIn.value
|
||||
isClockedIn.value = newClockStatus
|
||||
triggerOverlay(t(newClockStatus ? 'successClockIn' : 'successClockOut'), 'success');
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ formatLocalDate(event.timestamp) }}
|
||||
{{ event.timestamp }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatLocalTime(event.timestamp) }}
|
||||
{{ event.timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user