Merge branch 'main' into edison_dev2

This commit is contained in:
Edison
2025-07-24 08:38:58 +08:00
23 changed files with 2292 additions and 2003 deletions
-39
View File
@@ -1,39 +0,0 @@
import mysql from 'mysql2/promise'
import bcrypt from 'bcrypt'
import dotenv from 'dotenv'
dotenv.config()
async function hashPasswords() {
const db = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
port: process.env.DB_PORT,
})
try {
const [workers] = await db.execute('SELECT id, password_hash FROM workers')
for (const worker of workers) {
if (worker.password_hash && !worker.password_hash.startsWith('$2b$')) {
const saltRounds = 10
const hashedPassword = await bcrypt.hash(worker.password_hash, saltRounds)
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [
hashedPassword,
worker.id,
])
console.log(`Hashed password for worker with ID: ${worker.id}`)
}
}
console.log('Password hashing complete.')
} catch (error) {
console.error('Error hashing passwords:', error)
} finally {
await db.end()
}
}
hashPasswords()
+635
View File
@@ -0,0 +1,635 @@
import express from 'express';
import { Parser } from 'json2csv';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
export default function(db) {
const router = express.Router();
// Middleware to authenticate and authorize managers
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err || user.role !== 'manager') {
return res.status(403).json({ message: 'Forbidden' });
}
req.user = user;
next();
});
} else {
res.status(401).json({ message: 'Unauthorized' });
}
};
router.use(authenticateJWT);
// --- START: Date Management Routes ---
router.get('/enabled-dates', async (req, res) => {
try {
const [rows] = await db.execute('SELECT YEAR(enabled_date) as year, MONTH(enabled_date) as month, DAY(enabled_date) as day FROM enabled_dates');
// Format date safely using components from the database to avoid timezone shifts
const dates = rows.map(r => `${r.year}-${String(r.month).padStart(2, '0')}-${String(r.day).padStart(2, '0')}`);
res.json(dates);
} catch (error) {
console.error('Error fetching enabled dates:', error);
res.status(500).json({ message: 'Database error fetching enabled dates.' });
}
});
// Definitive version using a dedicated database connection
router.post('/enabled-dates/update', async (req, res) => {
let connection; // Define connection here to ensure it's accessible in the 'finally' block
try {
const { datesToEnable, datesToDisable } = req.body;
if (!Array.isArray(datesToEnable) || !Array.isArray(datesToDisable)) {
return res.status(400).json({ message: 'Invalid input format.' });
}
// 1. Get a single, dedicated connection from the pool
connection = await db.getConnection();
// 2. Process all deletions sequentially on the dedicated connection
for (const date of datesToDisable) {
await connection.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]);
}
// 3. Process all insertions sequentially on the dedicated connection
for (const date of datesToEnable) {
await connection.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]);
}
res.status(200).json({ message: 'Work schedule updated successfully.' });
} catch (error) {
console.error('Error updating work schedule:', error);
res.status(500).json({ message: 'Database error during schedule update.' });
} finally {
// 4. Ensure the dedicated connection is always released back to the pool
if (connection) {
connection.release();
}
}
});
// --- END: Date Management Routes ---
// --- ATTENDANCE & REPORTING ---
router.get('/failed-records', async (req, res) => {
try {
const { search = '', startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
const searchTerm = `%${search}%`;
const params = [startDate, `${endDate} 23:59:59`];
let searchQuery = '';
if (search) {
searchQuery = `AND (w.full_name LIKE ? OR w.department LIKE ?)`;
params.push(searchTerm, searchTerm);
}
const query = `
SELECT cr.worker_id, w.full_name, COUNT(*) as count
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
WHERE cr.event_type = 'failed'
AND cr.timestamp BETWEEN ? AND ?
${searchQuery}
GROUP BY cr.worker_id, w.full_name
ORDER BY count DESC
`;
const [rows] = await db.execute(query, params);
res.json(rows);
} catch (error) {
console.error('Failed records summary error:', error);
res.status(500).json({ message: 'Database error fetching failed records summary.', details: error.message });
}
});
router.get('/failed-records/details', async (req, res) => {
try {
const { workerId, startDate, endDate } = req.query;
if (!workerId || !startDate || !endDate) {
return res.status(400).json({ message: 'Worker ID, start date, and end date are required.' });
}
const query = `
SELECT cr.id, cr.timestamp, cr.event_type, COALESCE(qc.name, 'N/A') as qrCodeUsedName, cr.notes
FROM clock_records cr
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.worker_id = ?
AND cr.event_type = 'failed'
AND cr.timestamp BETWEEN ? AND ?
ORDER BY cr.timestamp DESC
`;
const params = [workerId, startDate, `${endDate} 23:59:59`];
const [rows] = await db.execute(query, params);
res.json(rows);
} catch (error) {
console.error('Failed records details error:', error);
res.status(500).json({ message: 'Database error fetching failed records details.', details: error.message });
}
});
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
router.get('/attendance-records/export-raw', async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT w.username, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
ORDER BY cr.timestamp DESC
`;
const [rows] = await db.execute(query, params);
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
const csv = json2csvParser.parse(rows);
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Raw attendance export error:', error);
res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message });
}
});
router.post('/add-record', authenticateJWT, async (req, res) => {
try {
const { workerId, eventType, timestamp, notes } = req.body
if (!workerId || !eventType || !timestamp) {
return res
.status(400)
.json({ message: 'Worker ID, event type, and timestamp are required.' })
}
// Check last event to prevent adding a duplicate event type
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[workerId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
const status = eventType === 'clock_in' ? 'in' : 'out'
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
}
// --- THIS IS THE FIX ---
const sanitizedTimestamp = timestamp.replace('T', ' ')
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
[workerId, eventType, sanitizedTimestamp, notes],
)
res.status(201).json({ message: 'Manual record added successfully.' })
} catch (error) {
console.error('Add manual record error:', error)
res.status(500).json({ message: 'Database error adding manual record.' })
}
})
router.get('/attendance-records/export', async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({ message: 'Start date and end date are required.' });
}
let workerIdClause = '';
const params = [startDate, `${endDate} 23:59:59`];
if (workerIds) {
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length > 0) {
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
}
}
const query = `
SELECT cr.worker_id, w.username, w.full_name, cr.event_type, cr.timestamp
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
ORDER BY cr.worker_id, cr.timestamp ASC
`;
const [rows] = await db.execute(query, params);
const workHoursByWorkerAndDay = {};
rows.forEach(row => {
const day = new Date(row.timestamp).toISOString().split('T')[0];
if (!workHoursByWorkerAndDay[row.worker_id]) {
workHoursByWorkerAndDay[row.worker_id] = {
username: row.username,
full_name: row.full_name,
days: {}
};
}
if (!workHoursByWorkerAndDay[row.worker_id].days[day]) {
workHoursByWorkerAndDay[row.worker_id].days[day] = [];
}
workHoursByWorkerAndDay[row.worker_id].days[day].push({
type: row.event_type,
time: new Date(row.timestamp)
});
});
const csvData = [];
for (const workerId in workHoursByWorkerAndDay) {
const workerData = workHoursByWorkerAndDay[workerId];
for (const day in workerData.days) {
const events = workerData.days[day];
let dailyTotalSeconds = 0;
let lastClockIn = null;
events.forEach(event => {
if (event.type === 'clock_in') {
lastClockIn = event.time;
} else if (event.type === 'clock_out' && lastClockIn) {
dailyTotalSeconds += (event.time - lastClockIn) / 1000;
lastClockIn = null;
}
});
csvData.push({
username: workerData.username,
full_name: workerData.full_name,
date: day,
work_hours: (dailyTotalSeconds / 3600).toFixed(2)
});
}
}
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'date', 'work_hours'] });
const csv = json2csvParser.parse(csvData);
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv);
} catch (error) {
console.error('Work hours export error:', error);
res.status(500).json({ message: 'Database error exporting work hours.', details: error.message });
}
});
router.get('/attendance-records', async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query;
if (!workerIds) {
return res.status(400).json({ message: 'Worker IDs are required.' });
}
// Ensure all IDs are numbers to prevent SQL injection.
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
if (idsArray.length === 0) {
return res.json([]);
}
// --- MODIFICATION START ---
// Instead of using a '?' placeholder for the IN clause, we build it directly.
// This is safe because we have already sanitized idsArray to be only numbers.
// This change is intended to bypass the specific bug in your MySQL version.
const inClause = idsArray.join(',');
let query = `
SELECT cr.id, w.full_name, cr.event_type, cr.timestamp,
COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName,
cr.latitude, cr.longitude, cr.notes
FROM clock_records cr
JOIN workers w ON cr.worker_id = w.id
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.worker_id IN (${inClause})`; // Placeholder is replaced here
const params = [];
// --- MODIFICATION END ---
if (startDate && endDate) {
query += ' AND cr.timestamp BETWEEN ? AND ?';
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
params.push(startDate, endOfDay);
}
query += ' ORDER BY w.full_name, cr.timestamp DESC';
const [rows] = await db.execute(query, params);
if (format === 'csv') {
const json2csvParser = new Parser({ fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'] });
const csv = json2csvParser.parse(rows);
res.header('Content-Type', 'text/csv').attachment('attendance.csv').send(csv);
} else {
res.json(rows);
}
} catch (error) {
console.error('Attendance records error:', error);
res.status(500).json({ message: 'Database error fetching attendance records.', details: error.message });
}
});
// --- All other manager routes remain the same ---
// GET all workers with filtering and pagination
router.get('/workers', async (req, res) => {
try {
const { search = '', page = 1, limit = 20 } = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const searchTerm = `%${search}%`;
let baseQuery = `
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at
FROM workers w
`;
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
const params = [];
const countParams = [];
let whereClauses = ["w.role = 'worker'"];
if (search) {
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
params.push(searchTerm, searchTerm);
countParams.push(searchTerm, searchTerm);
}
if (whereClauses.length > 0) {
const whereString = ` WHERE ${whereClauses.join(' AND ')}`;
baseQuery += whereString;
countQuery += whereString;
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`;
params.push(parseInt(limit), offset);
const [workers] = await db.execute(baseQuery, params);
const [[{ totalCount }]] = await db.execute(countQuery, countParams);
res.json({ workers, totalCount });
} catch (error) {
console.error('Get workers error:', error);
res.status(500).json({ message: 'Database error fetching workers.', details: error.message });
}
});
// POST (add) a new worker
router.post('/workers', async (req, res) => {
try {
const { username, password, fullName, department, position, role = 'worker' } = req.body;
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const [result] = await db.execute(
'INSERT INTO workers (username, password_hash, full_name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
[username, hashedPassword, fullName, role, department, position]
);
res.status(201).json({ id: result.insertId, username, fullName, role, department, position });
} catch (error) {
console.error('Add worker error:', error);
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' });
}
res.status(500).json({ message: 'Database error adding worker.', details: error.message });
}
});
// DELETE a worker
router.delete('/workers/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete worker error:', error);
res.status(500).json({ message: 'Database error deleting worker.', details: error.message });
}
});
// PUT (update) a worker's password
router.put('/workers/:workerId/password', async (req, res) => {
try {
const { workerId } = req.params;
const { newPassword } = req.body;
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' });
}
const hashedPassword = await bcrypt.hash(newPassword, 10);
const [result] = await db.execute("UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'", [hashedPassword, workerId]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Update password error:', error);
res.status(500).json({ message: 'Database error updating password.', details: error.message });
}
});
// PUT (clear) a worker's device UUID
router.put('/workers/:workerId/reset-device', async (req, res) => {
try {
const { workerId } = req.params;
const [result] = await db.execute("UPDATE workers SET device_uuid = NULL WHERE id = ?", [workerId]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Device registration cleared.' });
} catch (error) {
console.error('Reset device error:', error);
res.status(500).json({ message: 'Database error resetting device.', details: error.message });
}
});
// Geofence Management Routes
router.get('/geofences', async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
);
const geofences = rows.map(row => ({
...row,
coordinates: JSON.parse(row.coordinates || '[]')
}));
res.json(geofences);
} catch (error) {
console.error('Get geofences error:', error);
res.status(500).json({ message: 'Database error fetching geofences.', details: error.message });
}
});
router.post('/geofences', async (req, res) => {
try {
const { name, coordinates } = req.body;
if (!name || !coordinates) {
return res.status(400).json({ message: 'Geofence name and coordinates are required.' });
}
const [result] = await db.execute(
'INSERT INTO geofences (name, coordinates, is_active) VALUES (?, ?, ?)',
[name, JSON.stringify(coordinates), true]
);
const newGeofence = {
id: result.insertId,
name,
coordinates,
is_active: true,
};
res.status(201).json(newGeofence);
} catch (error) {
console.error('Add geofence error:', error);
res.status(500).json({ message: 'Database error adding geofence.', details: error.message });
}
});
router.put('/geofences/:id', async (req, res) => {
try {
const { id } = req.params;
const { is_active } = req.body;
if (typeof is_active !== 'boolean') {
return res.status(400).json({ message: 'is_active must be a boolean.' });
}
const [result] = await db.execute(
'UPDATE geofences SET is_active = ? WHERE id = ?',
[is_active, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Geofence not found.' });
}
res.json({ id, is_active });
} catch (error) {
console.error('Update geofence error:', error);
res.status(500).json({ message: 'Database error updating geofence.', details: error.message });
}
});
router.delete('/geofences/:id', async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Geofence not found.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete geofence error:', error);
res.status(500).json({ message: 'Database error deleting geofence.', details: error.message });
}
});
// QR Code Management Routes
router.get('/qr-codes', authenticateJWT, async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
);
res.json(rows);
} catch (error) {
console.error('Get QR codes error:', error);
res.status(500).json({ message: 'Database error fetching QR codes.' });
}
});
router.post('/qr-codes', authenticateJWT, async (req, res) => {
try {
const { name } = req.body;
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
const newQrCode = {
id: uuidv4(),
name,
is_active: true
};
await db.execute(
'INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)',
[newQrCode.id, newQrCode.name, newQrCode.is_active]
);
res.status(201).json(newQrCode);
} catch (error) {
console.error('Add QR code error:', error);
res.status(500).json({ message: 'Database error adding QR code.' });
}
});
router.put('/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params;
// Handle both isActive (camelCase) and is_active (snake_case)
const is_active = req.body.is_active ?? req.body.isActive;
if (typeof is_active !== 'boolean') {
return res.status(400).json({ message: 'Status must be a boolean value.' });
}
const [result] = await db.execute(
'UPDATE qr_codes SET is_active = ? WHERE id = ?',
[is_active, id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'QR Code not found.' });
}
res.json({ id, is_active });
} catch (error) {
console.error('Update QR code error:', error);
res.status(500).json({ message: 'Database error updating QR code.' });
}
});
router.delete('/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute(
'DELETE FROM qr_codes WHERE id = ?',
[id]
);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'QR Code not found.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete QR code error:', error);
res.status(500).json({ message: 'Database error deleting QR code.' });
}
});
return router;
}
+67 -677
View File
@@ -1,25 +1,21 @@
import express from 'express' // server.js
import cors from 'cors' import express from 'express';
import { Parser } from 'json2csv' import cors from 'cors';
import { v4 as uuidv4 } from 'uuid' import https from 'https';
import mysql from 'mysql2/promise' import http from 'http';
import dotenv from 'dotenv' import fs from 'fs';
import bcrypt from 'bcrypt' import path from 'path';
import jwt from 'jsonwebtoken' import { fileURLToPath } from 'url';
// --- FIX START --- import dotenv from 'dotenv';
// Import only the required functions from turf import mysql from 'mysql2/promise';
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf' import managerRoutes from './managerRoutes.js';
// --- FIX END --- import workerRoutes from './workerRoutes.js';
// Main function to start the server
async function startServer() { async function startServer() {
dotenv.config() dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') });
const app = express() const app = express();
const port = 3000
// --- Database Connection ---
const db = mysql.createPool({ const db = mysql.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USERNAME, user: process.env.DB_USERNAME,
@@ -29,676 +25,70 @@ async function startServer() {
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
}) });
try { try {
const connection = await db.getConnection() const connection = await db.getConnection();
console.log('Database connected successfully!') console.log('Database connected successfully!');
connection.release() connection.release();
} catch (error) { } catch (error) {
console.error('!!! DATABASE CONNECTION FAILED !!!') console.error('!!! DATABASE CONNECTION FAILED !!!');
console.error('Error:', error.message) console.error('Error:', error.message);
process.exit(1) process.exit(1);
} }
// Define the geofence polygon by calling the 'polygon' function directly const allowedOriginsFromEnv = (process.env.CORS_ALLOWED_ORIGINS || '').split(',').filter(Boolean);
const geofence = polygon([ const defaultAllowedOrigins = ['http://localhost:5173', 'https://localhost:5173', 'capacitor://localhost', 'ionic://localhost', 'http://localhost', 'https://localhost'];
[ const allowedOrigins = [...new Set([...defaultAllowedOrigins, ...allowedOriginsFromEnv])];
[101.80827335908509, 2.8350045747358337],
[101.80822799653066, 2.8340134829130363],
[101.80827902940462, 2.8335264317641418],
[101.80941309326164, 2.8332772427247335],
[101.81144873788423, 2.834596811345506],
[101.81166988033686, 2.8345911479647157],
[101.81199875885511, 2.83593336858695],
[101.80827335908509, 2.8350045747358337],
],
])
const corsOptions = {
app.use(cors()) origin: (origin, callback) => {
app.use(express.json()) // Allow requests with no origin (like mobile apps or curl requests)
if (!origin || allowedOrigins.includes(origin) || origin.startsWith('capacitor://') || origin.startsWith('ionic://')) {
// --- API Endpoints --- callback(null, true);
// Auth Endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body
const [rows] = await db.execute(
'SELECT id, role, password_hash FROM workers WHERE username = ?',
[username],
)
if (rows.length > 0) {
const user = rows[0]
const passwordMatch = await bcrypt.compare(password, user.password_hash)
if (passwordMatch) {
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, {
expiresIn: '1h',
})
res.json({ message: 'Login successful', token })
} else {
res.status(401).json({ message: 'Invalid credentials' })
}
} else { } else {
res.status(401).json({ message: 'Invalid credentials' }) console.log('CORS blocked origin:', origin);
callback(new Error('Not allowed by CORS'));
} }
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'],
exposedHeaders: ['Content-Range', 'X-Content-Range'],
};
app.use(cors(corsOptions));
app.use(express.json());
app.use('/api/managers', managerRoutes(db));
app.use('/api', workerRoutes(db));
const httpPort = process.env.HTTP_PORT || 3000;
const httpsPort = process.env.HTTPS_PORT || 3443;
const sslEnabled = process.env.SSL_ENABLED === 'true';
if (sslEnabled) {
try {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const keyPath = path.join(currentDir, 'key.pem');
const certPath = path.join(currentDir, 'cert.pem');
const httpsOptions = {
key: fs.readFileSync(keyPath),
cert: fs.readFileSync(certPath),
};
https.createServer(httpsOptions, app).listen(httpsPort, '0.0.0.0', () => {
console.log(`🔒 HTTPS Server is running on https://localhost:${httpsPort}`);
});
} catch (error) { } catch (error) {
console.error('Login error:', error) console.error('❌ Failed to start HTTPS server:', error.message);
res.status(500).json({ message: 'Database error during login.' })
}
})
// Middleware to verify JWT
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization
if (authHeader) {
const token = authHeader.split(' ')[1]
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.sendStatus(403)
}
req.user = user
next()
})
} else {
res.sendStatus(401)
} }
} }
// Worker Clock In/Out Endpoint http.createServer(app).listen(httpPort, '0.0.0.0', () => {
app.post('/api/clock', authenticateJWT, async (req, res) => { console.log(`🌐 HTTP Server is running on http://localhost:${httpPort}`);
try { });
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
// Geofencing check using the directly imported functions
const userLocation = point([longitude, latitude]);
const isWithinGeofence = booleanPointInPolygon(userLocation, geofence);
if (!isWithinGeofence) {
// User is outside the geofence, log a 'failed' attempt
// Calculate the distance from the geofence
const distance = pointToLineDistance(userLocation, geofence.geometry.coordinates[0], { units: 'meters' });
// Create a descriptive note
const notes = `Clock-in outside of the zone: ${distance.toFixed(2)} meters.`;
// Insert the failed attempt into the database
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[userId, 'failed', new Date(), qrCodeValue, latitude, longitude, notes]
);
// Return an error to the user
return res.status(403).json({ message: `You are not within the allowed work area.` });
// --- MODIFICATION END ---
}
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
qrCodeValue,
])
if (qrRows.length === 0) {
// This code is not in the database at all.
return res.status(400).json({ message: 'Invalid QR Code scanned.' })
}
if (!qrRows[0].is_active) {
// This code exists but has been deactivated.
return res
.status(400)
.json({ message: 'This QR Code has expired and is no longer active.' })
}
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
return res
.status(400)
.json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` })
}
const timestamp = new Date()
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?)',
[userId, eventType, timestamp, qrCodeValue, latitude, longitude],
)
res.status(201).json({ message: 'Clock event recorded successfully' })
} catch (error) {
console.error('Clock event error:', error)
res.status(500).json({ message: 'Database error during clock event.' })
}
})
// Fetch worker details endpoint
app.get('/api/workers/:id', authenticateJWT, async (req, res) => {
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) {
res.json({ full_name: rows[0].full_name })
} else {
res.status(404).json({ message: 'Worker not found.' })
}
} catch (error) {
console.error('Get worker details error:', error)
res.status(500).json({ message: 'Database error fetching worker details.' })
}
})
// Worker Status Endpoint
app.get('/api/worker/status/:userId', authenticateJWT, async (req, res) => {
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],
)
if (rows.length > 0) {
res.json({ eventType: rows[0].event_type })
} else {
res.json({ eventType: 'clock_out' }) // Default to clocked out
}
} catch (error) {
console.error('Worker status error:', error)
res.status(500).json({ message: 'Database error fetching status.' })
}
})
// Worker History Endpoint
app.get('/api/worker/clock-history/:userId', authenticateJWT, async (req, res) => {
try {
const { userId } = req.params
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries
const [rows] = await db.execute(
`SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`,
[userId],
)
res.json(rows)
} catch (error) {
console.error('Worker history error:', error)
res.status(500).json({ message: 'Database error fetching history.' })
}
})
app.put('/api/worker/change-password', authenticateJWT, async (req, res) => {
try {
const { userId } = req.user // Get user ID from JWT
const { currentPassword, newPassword } = req.body
if (!currentPassword || !newPassword) {
return res.status(400).json({ message: 'Current password and new password are required.' })
}
if (newPassword.length < 6) {
return res.status(400).json({ message: 'New password must be at least 6 characters long.' })
}
// Get user's current password hash
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId])
if (rows.length === 0) {
return res.status(404).json({ message: 'User not found.' })
}
const user = rows[0]
// Verify current password
const passwordMatch = await bcrypt.compare(currentPassword, user.password_hash)
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' })
}
// Hash new password
const saltRounds = 10
const newHashedPassword = await bcrypt.hash(newPassword, saltRounds)
// Update password in DB
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: 'Database error during password change.' })
}
})
// Manager: PUT (Update) a Worker's Password
app.put('/api/managers/workers/:workerId/password', authenticateJWT, async (req, res) => {
try {
// Ensure the user performing the action is a manager
if (req.user.role !== 'manager') {
return res
.status(403)
.json({ message: 'Forbidden: You do not have permission to perform this action.' })
}
const { workerId } = req.params
const { newPassword } = req.body
if (!newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Password must be at least 6 characters long.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(newPassword, saltRounds)
const [result] = await db.execute(
"UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'",
[hashedPassword, workerId],
)
if (result.affectedRows === 0) {
return res
.status(404)
.json({ message: 'Worker not found or you cannot change the password for this user.' })
}
res.status(200).json({ message: 'Password updated successfully.' })
} catch (error) {
console.error('Update password error:', error)
res.status(500).json({ message: 'Database error while updating password.' })
}
})
// GET all tags
app.get('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const [tags] = await db.execute('SELECT * FROM tags ORDER BY tag_name ASC')
res.json(tags)
} catch (error) {
console.error('Get tags error:', error)
res.status(500).json({ message: 'Database error fetching tags.' })
}
})
// POST a new tag
app.post('/api/managers/tags', authenticateJWT, async (req, res) => {
try {
const { tag_name } = req.body
if (!tag_name) {
return res.status(400).json({ message: 'Tag name is required.' })
}
const [result] = await db.execute('INSERT INTO tags (tag_name) VALUES (?)', [tag_name])
res.status(201).json({ id: result.insertId, tag_name })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'This tag already exists.' })
}
console.error('Add tag error:', error)
res.status(500).json({ message: 'Database error adding tag.' })
}
})
// NEW: DELETE a tag
app.delete('/api/managers/tags/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
// Optional: Check if the user is a manager before allowing deletion
if (req.user.role !== 'manager') {
return res.status(403).json({ message: 'Forbidden: Only managers can delete tags.' })
}
// Delete the tag from the 'tags' table.
// If 'worker_tags' table has ON DELETE CASCADE for tag_id,
// related entries in 'worker_tags' will automatically be removed.
const [result] = await db.execute('DELETE FROM tags WHERE id = ?', [id])
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Tag not found.' })
}
res.status(204).send() // 204 No Content for successful deletion
} catch (error) {
console.error('Delete tag error:', error)
res.status(500).json({ message: 'Database error deleting tag.' })
}
})
// POST to assign a tag to a worker
app.post('/api/managers/workers/:workerId/tags', authenticateJWT, async (req, res) => {
try {
const { workerId } = req.params
const { tagId } = req.body // Expects a single tag ID
if (!tagId) {
return res.status(400).json({ message: 'Tag ID is required.' })
}
// INSERT IGNORE prevents errors if the tag is already assigned to the worker
await db.query('INSERT IGNORE INTO worker_tags (worker_id, tag_id) VALUES (?, ?)', [
workerId,
tagId,
])
res.status(200).json({ message: 'Tag assigned successfully.' })
} catch (error) {
console.error('Assign tag error:', error)
res.status(500).json({ message: 'Database error assigning tag.' })
}
})
// DELETE to remove a tag from a worker
app.delete('/api/managers/workers/:workerId/tags/:tagId', authenticateJWT, async (req, res) => {
try {
const { workerId, tagId } = req.params
await db.query('DELETE FROM worker_tags WHERE worker_id = ? AND tag_id = ?', [
workerId,
tagId,
])
res.status(204).send() // 204 No Content for successful deletion
} catch (error) {
console.error('Remove tag error:', error)
res.status(500).json({ message: 'Database error removing tag.' })
}
})
// Find this endpoint in your server.js and replace it with the code below.
// Manager: GET All Workers (FIXED for older MySQL versions)
app.get('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { search = '', page = 1, limit = 20, tags = '' } = req.query
const offset = (parseInt(page) - 1) * parseInt(limit)
const searchTerm = `%${search}%`
const tagIds = tags
.split(',')
.filter((id) => id)
.map(Number)
const hasTagFilter = tagIds.length > 0
// Base queries
let baseQuery = `
SELECT
w.id, w.username, w.full_name, w.created_at,
(SELECT GROUP_CONCAT(t.tag_name SEPARATOR ', ')
FROM worker_tags wt_sub
JOIN tags t ON wt_sub.tag_id = t.id
WHERE wt_sub.worker_id = w.id) as tags
FROM workers w
`
let countQuery = `SELECT COUNT(DISTINCT w.id) as totalCount FROM workers w`
// Parameters for the queries
const params = []
const countParams = []
// Join with worker_tags if filtering
if (hasTagFilter) {
const joinClause = ` JOIN worker_tags wt ON w.id = wt.worker_id`
baseQuery += joinClause
countQuery += joinClause
}
// Common WHERE clause
const whereClause = ` WHERE w.role = 'worker' AND (w.full_name LIKE ? OR w.username LIKE ?)`
baseQuery += whereClause
countQuery += whereClause
params.push(searchTerm, searchTerm)
countParams.push(searchTerm, searchTerm)
// Add tag filtering logic
if (hasTagFilter) {
const tagPlaceholders = tagIds.map(() => '?').join(',')
const tagFilterClause = ` AND wt.tag_id IN (${tagPlaceholders})`
baseQuery += tagFilterClause
countQuery += tagFilterClause
// Add the tag IDs to the parameters individually
params.push(...tagIds)
countParams.push(...tagIds)
// --- FIX END ---
}
// Grouping and pagination for the main query
if (hasTagFilter) {
baseQuery += ` GROUP BY w.id HAVING COUNT(DISTINCT wt.tag_id) = ?`
params.push(tagIds.length)
}
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`
params.push(parseInt(limit), offset)
// Execute queries
const [workers] = await db.execute(baseQuery, params)
const [[{ totalCount }]] = await db.execute(countQuery, countParams)
res.json({ workers, totalCount })
} catch (error) {
// This is the error you are seeing
console.error('Get workers error:', error)
res.status(500).json({ message: 'Database error fetching workers.' })
}
})
// Manager: POST (Add new) Worker
app.post('/api/managers/workers', authenticateJWT, async (req, res) => {
try {
const { username, password, fullName, role = 'worker' } = req.body
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' })
}
if (!['worker', 'manager'].includes(role)) {
return res.status(400).json({ message: 'Invalid role specified.' })
}
const saltRounds = 10
const hashedPassword = await bcrypt.hash(password, saltRounds)
const [result] = await db.execute(
'INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, ?)',
[username, hashedPassword, fullName, role], // Pass role to query
)
res.status(201).json({ id: result.insertId, username, fullName, role })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' })
}
console.error('Add worker error:', error)
res.status(500).json({ message: 'Database error adding worker.' })
}
})
// Manager: DELETE Worker
app.delete('/api/managers/workers/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [
id,
])
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found or user is not a worker.' })
}
res.status(204).send()
} catch (error) {
console.error('Delete worker error:', error)
res.status(500).json({ message: 'Database error deleting worker.' })
}
})
// --- NEW --- Manager: POST (Add Manual Attendance Record)
// Note: For this to work, you may need to alter your database table:
// ALTER TABLE clock_records ADD COLUMN notes TEXT;
app.post('/api/managers/add-record', authenticateJWT, async (req, res) => {
try {
const { workerId, eventType, timestamp, notes } = req.body
if (!workerId || !eventType || !timestamp) {
return res
.status(400)
.json({ message: 'Worker ID, event type, and timestamp are required.' })
}
// Check last event to prevent adding a duplicate event type
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[workerId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
const status = eventType === 'clock_in' ? 'in' : 'out'
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
}
// --- THIS IS THE FIX ---
const sanitizedTimestamp = timestamp.replace('T', ' ')
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
[workerId, eventType, sanitizedTimestamp, notes],
)
res.status(201).json({ message: 'Manual record added successfully.' })
} catch (error) {
console.error('Add manual record error:', error)
res.status(500).json({ message: 'Database error adding manual record.' })
}
})
// Manager: GET Attendance Records
app.get('/api/managers/attendance-records', authenticateJWT, async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query
if (!workerIds) {
return res.status(400).json({ message: 'Worker IDs are required.' })
}
const idsArray = workerIds.split(',').map(Number)
if (idsArray.length === 0) return res.json([])
const placeholders = idsArray.map(() => '?').join(',')
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries, and select `notes`
let query = `SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id JOIN workers w ON cr.worker_id = w.id WHERE cr.worker_id IN (${placeholders})`;
const params = [...idsArray]
if (startDate && endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
query += ' AND cr.timestamp BETWEEN ? AND ?'
params.push(startDate, endOfDay)
}
query += ' ORDER BY w.full_name, cr.timestamp DESC'
const [rows] = await db.execute(query, params)
if (format === 'csv') {
// MODIFIED: Add 'notes' to CSV export
const json2csvParser = new Parser({
fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'],
})
const csv = json2csvParser.parse(rows)
res.header('Content-Type', 'text/csv')
res.attachment(`attendance-report-${new Date().toISOString().split('T')[0]}.csv`)
return res.send(csv)
}
res.json(rows)
} catch (error) {
console.error('Attendance records error:', error)
res.status(500).json({ message: 'Database error fetching attendance records.' })
}
})
// Manager: GET QR Codes
app.get('/api/managers/qr-codes', authenticateJWT, async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC',
)
res.json(rows)
} catch (error) {
console.error('Get QR codes error:', error)
res.status(500).json({ message: 'Database error fetching QR codes.' })
}
})
// Manager: POST QR Code
app.post('/api/managers/qr-codes', authenticateJWT, async (req, res) => {
try {
const { name } = req.body
if (!name) return res.status(400).json({ message: 'QR Code name is required.' })
const newQrCode = { id: uuidv4(), name, isActive: true }
await db.execute('INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)', [
newQrCode.id,
newQrCode.name,
newQrCode.isActive,
])
res
.status(201)
.json({ id: newQrCode.id, name: newQrCode.name, is_active: newQrCode.isActive })
} catch (error) {
console.error('Add QR code error:', error)
res.status(500).json({ message: 'Database error adding QR code.' })
}
})
// Manager: PUT QR Code
app.put('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const { isActive } = req.body
if (typeof isActive !== 'boolean')
return res.status(400).json({ message: 'isActive must be a boolean.' })
const [result] = await db.execute('UPDATE qr_codes SET is_active = ? WHERE id = ?', [
isActive,
id,
])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.json({ id, isActive })
} catch (error) {
console.error('Update QR code error:', error)
res.status(500).json({ message: 'Database error updating QR code.' })
}
})
// Manager: DELETE QR Code
app.delete('/api/managers/qr-codes/:id', authenticateJWT, async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute('DELETE FROM qr_codes WHERE id = ?', [id])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.status(204).send()
} catch (error) {
console.error('Delete QR code error:', error)
res.status(500).json({ message: 'Database error deleting QR code.' })
}
})
// Manager: GET single worker's details
app.get('/api/managers/worker/:id', authenticateJWT, async (req, res) => {
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) {
res.json(rows[0])
} else {
res.status(404).json({ message: 'Worker not found.' })
}
} catch (error) {
console.error('Get single worker error:', error)
res.status(500).json({ message: 'Database error fetching worker details.' })
}
})
// --- Server Start ---
// const httpsOptions = {
// key: fs.readFileSync(process.env.SSL_KEY_PATH),
// cert: fs.readFileSync(process.env.SSL_CERT_PATH),
// }
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`)
})
} }
startServer() startServer();
+230
View File
@@ -0,0 +1,230 @@
import express from 'express';
import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
// Removed unused import
async function validateDeviceForUser(userId, deviceUuid, db) {
const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]);
if (userRows.length === 0) return { valid: false, message: 'User not found' };
const { device_uuid } = userRows[0];
if (!device_uuid) {
await db.execute('UPDATE workers SET device_uuid = ? WHERE id = ?', [deviceUuid, userId]);
return { valid: true, message: 'Device registered successfully' };
}
return { valid: device_uuid === deviceUuid, message: 'Device validation failed' };
}
async function isClockingEnabled(db) {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD format
const [rows] = await db.execute('SELECT 1 FROM enabled_dates WHERE enabled_date = ? LIMIT 1', [today]);
return rows.length > 0;
}
export default function(db) {
const router = express.Router();
router.post('/auth/login', async (req, res) => {
const { username, password, deviceUuid } = req.body;
const [rows] = await db.execute('SELECT id, role, password_hash FROM workers WHERE username = ?', [username]);
if (rows.length === 0) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const user = rows[0];
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}
if (deviceUuid && user.role !== 'manager') {
const deviceValidation = await validateDeviceForUser(user.id, deviceUuid, db);
if (!deviceValidation.valid) {
return res.status(403).json({ message: deviceValidation.message });
}
}
const token = jwt.sign({ userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
});
const authenticateJWT = (req, res, next) => {
const authHeader = req.headers.authorization;
if (authHeader) {
const token = authHeader.split(' ')[1];
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = user;
next();
});
} else {
res.status(401).json({ message: 'Authorization header required' });
}
};
router.use(authenticateJWT);
// Definitive version with distance calculation and specific error messages
// Definitive version with distance calculation and specific error messages
router.post('/clock', async (req, res) => {
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body;
const currentTimestamp = new Date().toISOString().slice(0, 19).replace('T', ' ');
// 1. Kill Switch Enforcement
const clockingAllowed = await isClockingEnabled(db);
if (!clockingAllowed) {
const note = 'Clock-in/out function is not enabled for today.';
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)',
[userId, qrCodeValue, latitude, longitude, note, currentTimestamp]
);
return res.status(403).json({ message: 'error.clockingDisabled' });
}
// 2. Geofence Validation with Distance Calculation
if (latitude != null && longitude != null) {
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 db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
return res.status(403).json({ message: 'error.noActiveGeofence' });
}
const userLocation = point([longitude, latitude]);
const parsedPolygons = [];
let isInside = false;
for (const fence of activeFences) {
try {
if (!fence.coordinates) continue;
const coordinates = JSON.parse(fence.coordinates);
const fencePolygon = polygon([coordinates]);
parsedPolygons.push(fencePolygon); // Save for distance calculation
if (booleanPointInPolygon(userLocation, fencePolygon)) {
isInside = true;
break;
}
} catch (e) {
console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e });
}
}
if (!isInside) {
let minDistance = Infinity;
for (const p of parsedPolygons) {
const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' });
if (distance < minDistance) {
minDistance = distance;
}
}
const distanceString = minDistance.toFixed(2);
const note = `Outside geofence by ${distanceString}m`;
await db.execute('INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, ?)', [userId, qrCodeValue, latitude, longitude, note, currentTimestamp]);
return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` });
}
}
// 3. QR Code and Status Validation
if (qrCodeValue !== 'FORCE_CLOCK_OUT') {
const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]);
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'error.invalidQrCode' });
}
}
const [lastEvent] = await db.execute('SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', [userId]);
if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) {
const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut';
return res.status(400).json({ message: errorKey });
}
// 4. Record Successful Event
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
[userId, eventType, qrCodeValue, latitude, longitude, currentTimestamp]
);
res.status(201).json({ message: 'Clock event recorded.' });
} catch (error) {
console.error('!!! CRITICAL ERROR in /clock route !!!:', error);
res.status(500).json({ message: 'error.criticalServer' });
}
});
router.get('/workers/:id', async (req, res) => {
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]);
});
router.get('/worker/status/:userId', async (req, res) => {
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' });
});
router.get('/worker/clock-history/:userId', async (req, res) => {
const { userId } = req.params;
const [rows] = await db.execute(`
SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName
FROM clock_records cr
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC
`, [userId]);
res.json(rows);
});
router.put('/worker/change-password', async (req, res) => {
const { userId } = req.user;
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword || newPassword.length < 6) {
return res.status(400).json({ message: 'Invalid input.' });
}
const [rows] = await db.execute('SELECT password_hash FROM workers WHERE id = ?', [userId]);
const passwordMatch = await bcrypt.compare(currentPassword, rows[0].password_hash);
if (!passwordMatch) {
return res.status(401).json({ message: 'Incorrect current password.' });
}
const newHashedPassword = await bcrypt.hash(newPassword, 10);
await db.execute('UPDATE workers SET password_hash = ? WHERE id = ?', [newHashedPassword, userId]);
res.json({ message: 'Password updated successfully.' });
});
router.post('/location/update', async (req, res) => {
// Do nothing, always return location updated
res.json({ message: 'Location updated.' });
});
router.post('/device/register', async (req, res) => {
const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.status(result.valid ? 200 : 409).json(result);
});
router.post('/device/validate', async (req, res) => {
const { userId, deviceUuid } = req.body;
const result = await validateDeviceForUser(userId, deviceUuid, db);
res.json(result);
});
router.get('/security/status/:userId', async (req, res) => {
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]);
res.json({
latestSecurityCheck: securityRows[0] || null,
recentAlerts: alertRows,
});
});
router.get('/security/app-blacklist', async (req, res) => {
const [rows] = await db.execute('SELECT package_name FROM app_blacklist');
res.json(rows.map(row => row.package_name));
});
return router;
}
+207
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -8,6 +8,13 @@ export default defineConfig([
{ {
name: 'app/files-to-lint', name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'], files: ['**/*.{js,mjs,jsx,vue}'],
rules: {
'no-unused-vars': ['error', {
'argsIgnorePattern': '^_', // Ignore unused parameters starting with underscore
'varsIgnorePattern': '^_', // Ignore unused variables starting with underscore
'caughtErrorsIgnorePattern': '^_' // Ignore unused catch error parameters
}]
}
}, },
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']), globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
+13
View File
@@ -0,0 +1,13 @@
-- Simple geofence management table
CREATE TABLE IF NOT EXISTS `geofences` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`coordinates` text NOT NULL,
`is_active` tinyint(1) DEFAULT 1,
`created_at` timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8;
-- Insert current geofence as default
INSERT INTO `geofences` (`name`, `coordinates`) VALUES
('Main Work Area', '[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]');
-33
View File
@@ -1,33 +0,0 @@
# Host: localhost (Version: 5.7.26)
# Date: 2025-06-30 17:33:04
# Generator: MySQL-Front 5.3 (Build 4.234)
/*!40101 SET NAMES utf8 */;
#
# Structure for table "clock_records"
#
DROP TABLE IF EXISTS `clock_records`;
CREATE TABLE `clock_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`worker_id` int(11) NOT NULL,
`event_type` enum('clock_in','clock_out','failed') NOT NULL,
`timestamp` datetime NOT NULL,
`qr_code_id` varchar(255) DEFAULT NULL,
`latitude` decimal(10,8) DEFAULT NULL,
`longitude` decimal(11,8) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`notes` text,
PRIMARY KEY (`id`),
KEY `worker_id` (`worker_id`),
KEY `qr_code_id` (`qr_code_id`)
) ENGINE=MyISAM AUTO_INCREMENT=44 DEFAULT CHARSET=utf8 COMMENT='Logs every clock-in and clock-out event for all workers.';
#
# Data for table "clock_records"
#
/*!40000 ALTER TABLE `clock_records` DISABLE KEYS */;
INSERT INTO `clock_records` VALUES (2,1,'clock_out','2025-06-10 17:30:00','FACTORY-MAIN-ENTRANCE',NULL,NULL,'2025-06-13 13:39:51',NULL,NULL),(3,1,'clock_in','2025-06-13 14:09:50','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:09:49',NULL,NULL),(4,1,'clock_out','2025-06-13 14:10:01','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:00',NULL,NULL),(5,2,'clock_in','2025-06-13 14:10:41','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:41',NULL,NULL),(6,2,'clock_out','2025-06-13 14:10:55','FACTORY-MAIN-ENTRANCE',3.15785050,101.72055800,'2025-06-13 14:10:54',NULL,NULL),(7,2,'clock_in','2025-06-13 14:17:51','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:17:50',NULL,NULL),(8,2,'clock_out','2025-06-13 14:17:56','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:17:56',NULL,NULL),(9,4,'clock_in','2025-06-13 14:59:56','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 14:59:55',NULL,NULL),(10,4,'clock_out','2025-06-13 15:00:08','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15785050,101.72055800,'2025-06-13 15:00:07',NULL,NULL),(16,8,'clock_in','2025-06-12 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(17,8,'clock_out','2025-06-12 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(18,10,'clock_in','2025-06-13 08:45:00','FACTORY-MAIN-ENTRANCE',NULL,NULL,'2025-06-13 16:02:39',NULL,NULL),(19,4,'clock_in','2025-06-12 09:28:19','d654a6bf-2b48-49e9-95c8-4fe9af6c3e44',3.15792400,101.72059600,'2025-06-16 11:28:19',NULL,NULL),(20,4,'clock_out','2025-06-12 17:28:56','WAREHOUSE-SECTION-A',3.15760800,101.72043600,'2025-06-16 11:28:55',NULL,NULL),(22,8,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(23,8,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(24,8,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(25,8,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:19:37',NULL,NULL),(30,6,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(31,6,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(32,6,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(33,6,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(34,10,'clock_in','2025-06-13 09:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(35,10,'clock_out','2025-06-13 12:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(36,10,'clock_in','2025-06-13 14:05:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(37,10,'clock_out','2025-06-13 17:15:00','WAREHOUSE-SECTION-A',NULL,NULL,'2025-06-16 14:39:58',NULL,NULL),(38,1,'clock_in','2025-06-17 10:51:00','WAREHOUSE-SECTION-A',3.15786900,101.72065000,'2025-06-17 10:50:59',NULL,NULL),(39,1,'clock_out','2025-06-17 10:58:00',NULL,NULL,NULL,'2025-06-17 10:59:44','testing',NULL),(40,8,'clock_in','2025-06-26 11:39:23','d7ac9594-ad9f-48dc-b984-5a9e7ea7e995',3.15950000,101.71790000,'2025-06-26 11:39:22',NULL,NULL),(41,8,'clock_out','2025-06-26 11:42:00',NULL,NULL,NULL,'2025-06-26 11:43:07','testing',NULL),(42,4,'clock_in','2025-06-30 15:31:24','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 15:31:23',NULL,NULL),(43,4,'clock_out','2025-06-30 15:33:07','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 15:33:07',NULL,NULL),(44,4,'failed','2025-06-30 15:49:09','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 15:49:08','Clock-in outside of the zone: 284575.63 meters.',284575.63),(45,4,'failed','2025-06-30 15:57:56','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 15:57:56','Clock-in outside of the zone: 284575.63 meters.',284575.63),(46,4,'clock_in','2025-06-30 16:03:51','9f72afba-ebb6-445d-a7fc-58df9902777b',2.83451211,101.80955708,'2025-06-30 16:03:50',NULL,NULL),(47,4,'failed','2025-06-30 16:04:15','9f72afba-ebb6-445d-a7fc-58df9902777b',1.28390000,103.84900000,'2025-06-30 16:04:14','Clock-in outside of the zone: 284575.63 meters.',284575.63);
/*!40000 ALTER TABLE `clock_records` ENABLE KEYS */;
+46 -8
View File
@@ -15,12 +15,12 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.5.0", "dotenv": "^16.6.1",
"express": "^5.1.0", "express": "^5.1.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"json2csv": "^6.0.0-alpha.2", "json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mysql2": "^3.14.1", "mysql2": "^3.14.2",
"primevue": "^4.3.5", "primevue": "^4.3.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -31,6 +31,8 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@types/leaflet": "^1.9.20",
"@types/leaflet-draw": "^1.0.12",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@@ -38,6 +40,8 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0", "eslint-plugin-vue": "~10.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.5.3", "prettier": "3.5.3",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.10",
@@ -4458,6 +4462,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.20",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
"integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/leaflet-draw": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.12.tgz",
"integrity": "sha512-ayjGxelc3pp7532852Qn/LYHs/CHOcUqM9iDVsXuIXbIGfM2h3OtsHO/sQzFO6GAz2IvslPupgJaYocsY8NH+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/leaflet": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.0.4", "version": "24.0.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz",
@@ -5505,9 +5529,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.5.0", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -7039,6 +7063,20 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/leaflet-draw": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz",
"integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -7550,9 +7588,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": { "node_modules/mysql2": {
"version": "3.14.1", "version": "3.14.2",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.2.tgz",
"integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", "integrity": "sha512-YD6mZMeoypmheHT6b2BrVmQFvouEpRICuvPIREulx2OvP1xAxxeqkMQqZSTBefv0PiOBKGYFa2zQtY+gf/4eQw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"aws-ssl-profiles": "^1.1.1", "aws-ssl-profiles": "^1.1.1",
+6 -2
View File
@@ -20,12 +20,12 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.5.0", "dotenv": "^16.6.1",
"express": "^5.1.0", "express": "^5.1.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"json2csv": "^6.0.0-alpha.2", "json2csv": "^6.0.0-alpha.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mysql2": "^3.14.1", "mysql2": "^3.14.2",
"primevue": "^4.3.5", "primevue": "^4.3.5",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
@@ -36,6 +36,8 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.22.0", "@eslint/js": "^9.22.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@types/leaflet": "^1.9.20",
"@types/leaflet-draw": "^1.0.12",
"@vitejs/plugin-vue": "^5.2.3", "@vitejs/plugin-vue": "^5.2.3",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@@ -43,6 +45,8 @@
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-plugin-vue": "~10.0.0", "eslint-plugin-vue": "~10.0.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "3.5.3", "prettier": "3.5.3",
"tailwindcss": "^4.1.10", "tailwindcss": "^4.1.10",
+37 -12
View File
@@ -1,31 +1,56 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export async function apiFetch(endpoint, options = {}) { export async function apiFetch(endpoint, options = {}) {
const token = sessionStorage.getItem('token') const token = sessionStorage.getItem('token');
const defaultHeaders = { const defaultHeaders = {
'ngrok-skip-browser-warning': 'true', 'ngrok-skip-browser-warning': 'true',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
} };
if (token) { if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}` defaultHeaders['Authorization'] = `Bearer ${token}`;
}
// If body is FormData, don't set Content-Type header
if (options.body instanceof FormData) {
delete defaultHeaders['Content-Type'];
} }
const response = await fetch(`${API_BASE_URL}${endpoint}`, { const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options, ...options,
headers: defaultHeaders, headers: defaultHeaders,
}) });
if (!response.ok) { if (!response.ok) {
// Try to parse the error response body from the server // Check content type of the error response
const errorData = await response.json() const contentType = response.headers.get('content-type');
throw new Error(errorData.message || `API call failed with status: ${response.status}`) let errorData;
}
if (response.status === 204) { // If the server sends back a JSON error, parse it.
return null if (contentType && contentType.includes('application/json')) {
errorData = await response.json();
// Use the 'details' from our backend error structure, or the message, or a default
throw new Error(errorData.details || errorData.message || `API call failed with status: ${response.status}`);
} else {
// If the server sends back HTML or plain text, use that as the error message.
// This prevents the "Unexpected token '<'" error.
const textError = await response.text();
throw new Error(textError || `Server returned an unhandled error with status: ${response.status}`);
}
} }
return response.json() // Handle successful responses
if (response.status === 204) {
return null; // No Content
}
// Handle file downloads like CSV
const disposition = response.headers.get('content-disposition');
if (disposition && disposition.includes('attachment')) {
return response.blob();
}
return response.json();
} }
-653
View File
@@ -1,653 +0,0 @@
<template>
<div class="flex flex-col lg:flex-row gap-8 attendance-reporting-layout">
<div class="selection-panel w-full lg:w-1/2">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 h-full flex flex-col">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('selectWorkers') }}</h2>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 mb-4">
<div class="search-box relative flex-grow">
<input type="text" v-model="searchQuery" :placeholder="$t('searchWorkerPlaceholder')"
class="form-input 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-700 text-gray-900 dark:text-white"
@keydown.enter.prevent="handleSearch" @keydown.down.prevent="navigateResults(1)"
@keydown.up.prevent="navigateResults(-1)" @keydown.esc.prevent="clearSearch" />
<div v-if="searchResults.length > 0"
class="search-results-list absolute top-full left-0 right-0 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 border-t-0 rounded-b-md shadow-lg z-10 max-h-60 overflow-y-auto">
<ul>
<li v-for="(worker, index) in searchResults" :key="worker.id"
:class="{ 'bg-blue-100 dark:bg-blue-900': index === highlightedIndex }"
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-100 dark:border-gray-700 last:border-b-0 text-gray-900 dark:text-white"
@click="selectWorker(worker)" @mouseenter="highlightedIndex = index">
{{ worker.full_name }}
</li>
</ul>
</div>
</div>
<button @click="handleSelectAll"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
{{ $t('selectAll') }}
</button>
</div>
<div class="tag-selection mb-6">
<div class="form-group flex flex-col gap-2">
<label for="tag-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
$t('addWorkersByTag') }}</label>
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 items-end">
<select id="tag-select" v-model="selectedTagId"
class="form-input 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-700 text-gray-900 dark:text-white">
<option :value="null" disabled>{{ $t('chooseTag') }}</option>
<option v-for="tag in allTags" :key="tag.id" :value="tag.id">
{{ tag.tag_name }}
</option>
</select>
<button @click="addWorkersByTag" :disabled="!selectedTagId"
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
{{ $t('addByTag') }}
</button>
</div>
</div>
</div>
<div class="selected-workers-list flex-grow">
<h4 class="text-lg font-semibold mb-3 text-gray-800 dark:text-white">
{{ $t('selectedForReport', { count: selectedWorkers.length }) }}
</h4>
<ul v-if="isSelectAllActive" class="space-y-2">
<li
class="flex justify-between items-center bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-4 py-2 text-gray-700 dark:text-gray-200">
<span>{{ $t('allWorkersSelected', { count: selectedWorkers.length }) }}</span>
<button @click="clearAllSelection"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-xl leading-none">
&times;
</button>
</li>
</ul>
<ul v-else-if="selectedWorkers.length > 0" class="flex flex-wrap gap-2">
<li v-for="worker in selectedWorkers" :key="worker.id"
class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-1 text-gray-700 dark:text-gray-200 text-sm font-medium">
<span>{{ worker.full_name }}</span>
<button @click="removeWorker(worker.id)"
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-base leading-none">
&times;
</button>
</li>
</ul>
<p v-else class="empty-state text-gray-500 dark:text-gray-400 italic mt-4">
{{ $t('noWorkersSelected') }}
</p>
</div>
</section>
</div>
<div class="results-panel w-full lg:w-1/2">
<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('reportSettings') }}</h2>
<div class="filters flex flex-col sm:flex-row gap-4 border-b border-gray-200 dark:border-gray-700 pb-6 mb-6">
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate')
}}</label>
<input type="date" id="start-date" v-model="filters.startDate"
class="form-input 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="form-group flex flex-col gap-2 w-full sm:w-1/2">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
}}</label>
<input type="date" id="end-date" v-model="filters.endDate"
class="form-input 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="overtime-settings-section flex flex-col gap-5" v-if="selectedWorkers.length > 0">
<div class="worker-salaries border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">
{{ $t('monthlySalary') }}
</h4>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
{{ $t('salaryAppliedNote') }}
</p>
<div class="form-group flex flex-col gap-2">
<input id="monthly-salary" type="number" v-model.number="monthlySalary"
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('salaryPlaceholder')" />
</div>
</div>
<div class="factor-settings border-b border-gray-200 dark:border-gray-700 pb-5">
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">{{ $t('otFactors') }}</h4>
<div class="factor-input flex flex-col sm:flex-row gap-4">
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('weekendFactor') }}</p>
<input id="rest-day-factor" type="number" v-model.number="overtimeSettings.restDayFactor"
class="form-input 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="form-group flex flex-col gap-2 w-full sm:w-1/2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('holidayFactor') }}</p>
<input id="holiday-factor" type="number" v-model.number="overtimeSettings.publicHolidayFactor"
class="form-input 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>
<div class="holiday-picker">
<h5 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
{{ $t('selectPublicHolidays') }}
</h5>
<div class="calendar border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-w-sm mx-auto">
<div
class="calendar-header flex justify-between items-center font-semibold mb-4 text-gray-800 dark:text-white">
<button @click="changeMonth(-1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
&lt;
</button>
<span>{{ calendarGrid.monthName }} {{ calendarGrid.year }}</span>
<button @click="changeMonth(1)"
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
&gt;
</button>
</div>
<div
class="calendar-weekdays grid grid-cols-7 text-center text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2">
<div v-for="day in calendarGrid.weekdayLabels" :key="day" class="weekday">
{{ day }}
</div>
</div>
<div class="calendar-days grid grid-cols-7 text-center">
<div v-for="(day, index) in calendarGrid.grid" :key="index"
class="day-cell h-9 w-9 flex items-center justify-center rounded-full cursor-pointer transition-colors duration-100"
:class="{
'text-gray-400 dark:text-gray-500 cursor-default': day.type === 'padding',
'bg-blue-600 text-white font-bold': day.isHoliday,
'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-white':
day.type === 'day' && !day.isHoliday,
}" @click="day.type === 'day' && toggleHoliday(day.dateString)">
<span v-if="day.type === 'day'">{{ day.date }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-buttons mt-8 flex justify-end">
<button @click="generateReport" :disabled="!canGenerate"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-3 rounded-md w-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
{{ $t('generateReport') }}
</button>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8" v-if="reportGenerated">
<div v-if="overtimeReport" class="overtime-report mb-8">
<div class="report-header flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-2 sm:mb-0">
{{ $t('overtimePaySummary') }}
</h4>
<button @click="exportOtSummaryCsv" :disabled="!overtimeReport"
class="button-primary bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('exportOtSummary') }}
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-[400px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('worker') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('totalHoursWorked') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('totalOtPay') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="(report, name) in overtimeReport" :key="name"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ report.totalHours.toFixed(2) }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ report.totalOtPay.toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="raw-logs" v-if="reportData.length > 0">
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
{{ $t('rawAttendanceData') }}
</h4>
<div v-for="(group, workerName) in groupedReportData" :key="workerName" class="worker-group mb-6 last:mb-0">
<h5
class="worker-group-header bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md mb-3">
{{ workerName }}
</h5>
<div class="overflow-x-auto">
<table class="min-w-[500px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('event') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('timsstamp') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('location') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('notes') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="record in group" :key="record.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3">
<span class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase text-white" :class="{
'bg-green-500': record.event_type === 'clock_in',
'bg-red-500': record.event_type === 'clock_out',
'bg-yellow-500': record.event_type === 'failed',
}">
{{ record.event_type.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(record.timestamp).toLocaleString() }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ record.qrCodeUsedName }}
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8 text-center" v-if="loadingReport">
<p class="text-lg font-medium text-gray-700 dark:text-gray-200">{{ $t('loadingReport') }}</p>
</section>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { apiFetch } from '@/api.js'
// --- STATE ---
const searchQuery = ref('')
const searchResults = ref([])
const highlightedIndex = ref(-1)
const isSelectAllActive = ref(false)
const selectedWorkers = ref([])
const filters = ref({ startDate: '', endDate: '' })
// NEW: State for tag selection
const allTags = ref([])
const selectedTagId = ref(null)
const loadingReport = ref(false)
const reportGenerated = ref(false)
const reportData = ref([])
const overtimeReport = ref(null)
// --- OT & SALARY STATE ---
const monthlySalary = ref(null)
const overtimeSettings = ref({
restDayFactor: 2,
publicHolidayFactor: 3,
publicHolidays: [],
})
// --- CALENDAR STATE ---
const calendarDate = ref(new Date())
// --- COMPUTED ---
const canGenerate = computed(() => {
return (
selectedWorkers.value.length > 0 &&
filters.value.startDate &&
filters.value.endDate &&
monthlySalary.value &&
monthlySalary.value > 0
)
})
const groupedReportData = computed(() => {
return reportData.value.reduce((groups, record) => {
const key = record.full_name
if (!groups[key]) {
groups[key] = []
}
groups[key].push(record)
return groups
}, {})
})
const calendarGrid = computed(() => {
const year = calendarDate.value.getFullYear()
const month = calendarDate.value.getMonth()
const firstDayOfMonth = new Date(year, month, 1).getDay() // 0=Sun
const daysInMonth = new Date(year, month + 1, 0).getDate()
const grid = []
const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
for (let i = 0; i < firstDayOfMonth; i++) {
grid.push({ type: 'padding' })
}
for (let day = 1; day <= daysInMonth; day++) {
const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
grid.push({
type: 'day',
date: day,
dateString: dateString,
isHoliday: overtimeSettings.value.publicHolidays.includes(dateString),
})
}
return {
grid,
weekdayLabels,
monthName: calendarDate.value.toLocaleString('default', { month: 'long' }),
year,
}
})
// --- METHODS ---
const fetchInitialData = async () => {
// Setup default dates
const today = new Date()
filters.value.endDate = today.toISOString().split('T')[0]
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
calendarDate.value = new Date(filters.value.startDate + 'T00:00:00')
// NEW: Fetch all available tags
try {
allTags.value = await apiFetch('/api/managers/tags')
} catch (err) {
console.error('Failed to fetch tags', err)
}
}
const addWorkersByTag = async () => {
if (!selectedTagId.value) return
try {
// Use the API to get all workers with the selected tag
const data = await apiFetch(`/api/managers/workers?tags=${selectedTagId.value}&limit=1000`)
// Add the fetched workers to the selection list, avoiding duplicates
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((sw) => sw.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
// Reset the dropdown
selectedTagId.value = null
} catch (err) {
console.error('Failed to fetch workers by tag', err)
alert($t('tagLoadError'));
}
}
const handleSearch = async () => {
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
selectWorker(searchResults.value[highlightedIndex.value])
} else if (searchQuery.value === '') {
await handleSelectAll()
searchResults.value = []
highlightedIndex.value = -1
} else {
await fetchWorkers()
}
}
const fetchWorkers = async (selectAll = false) => {
try {
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&limit=1000`)
if (selectAll) {
data.workers.forEach((worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
})
} else {
searchResults.value = data.workers
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
}
} catch (err) {
console.error('Failed to search workers', err)
}
}
const clearSearch = () => {
searchQuery.value = ''
searchResults.value = []
highlightedIndex.value = -1
}
const navigateResults = (direction) => {
if (searchResults.value.length === 0) return
const newIndex = highlightedIndex.value + direction
if (newIndex >= 0 && newIndex < searchResults.value.length) {
highlightedIndex.value = newIndex
}
}
const handleSelectAll = async () => {
await fetchWorkers(true)
isSelectAllActive.value = true
}
const clearAllSelection = () => {
selectedWorkers.value = []
isSelectAllActive.value = false
}
const selectWorker = (worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
isSelectAllActive.value = false
clearSearch()
}
const removeWorker = (workerId) => {
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
isSelectAllActive.value = false
}
const generateReport = async () => {
if (!canGenerate.value) {
alert($t('generateReportError'));
return
}
loadingReport.value = true
reportGenerated.value = false
reportData.value = []
overtimeReport.value = null
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
const url = `/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
try {
const fetchedRecords = await apiFetch(url)
reportData.value = fetchedRecords
const otResults = {}
const hourlyRate = monthlySalary.value / 26 / 8
for (const worker of selectedWorkers.value) {
const workerRecords = fetchedRecords
.filter((r) => r.full_name === worker.full_name)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
const dailyBreakdown = []
let clockInTime = null
for (const record of workerRecords) {
if (record.event_type === 'clock_in' && !clockInTime) {
clockInTime = new Date(record.timestamp)
} else if (record.event_type === 'clock_out' && clockInTime) {
const clockOutTime = new Date(record.timestamp)
const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60)
if (hours > 0) {
const date = clockInTime.toISOString().split('T')[0]
const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date)
const factor = isPublicHoliday
? overtimeSettings.value.publicHolidayFactor
: overtimeSettings.value.restDayFactor
const otPay = hours * hourlyRate * factor
dailyBreakdown.push({ hours, otPay })
}
clockInTime = null
}
}
const totalHours = dailyBreakdown.reduce((sum, day) => sum + day.hours, 0)
const totalOtPay = dailyBreakdown.reduce((sum, day) => sum + day.otPay, 0)
otResults[worker.full_name] = { totalHours, totalOtPay }
}
overtimeReport.value = otResults
reportGenerated.value = true
} catch (err) {
console.error('Failed to generate report', err)
alert($t('reportGenerationError'));
} finally {
loadingReport.value = false
}
}
const exportOtSummaryCsv = () => {
if (reportData.value.length === 0) return
const headers = [
'Worker Name',
'Event',
'Timestamp',
'Location',
'Session Hours',
'Session OT Wage (RM)',
]
const allRows = []
const hourlyRate = monthlySalary.value / 26 / 8
for (const worker of selectedWorkers.value) {
const workerRecords = reportData.value
.filter((r) => r.full_name === worker.full_name)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
let clockInRecord = null
let workerTotalWage = 0
for (const record of workerRecords) {
const eventType = record.event_type === 'clock_in' ? 'Clock In' : 'Clock Out'
const timestamp = new Date(record.timestamp).toLocaleString()
let row = [
`${worker.full_name}`,
`${eventType}`,
`${timestamp}`,
`${record.qrCodeUsedName}`,
]
if (record.event_type === 'clock_in') {
if (!clockInRecord) {
clockInRecord = record
}
row.push('', '')
allRows.push(row.join(','))
} else if (record.event_type === 'clock_out') {
if (clockInRecord) {
const clockOutTime = new Date(record.timestamp)
const clockInTime = new Date(clockInRecord.timestamp)
const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60)
if (hours > 0) {
const date = clockInTime.toISOString().split('T')[0]
const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date)
const factor = isPublicHoliday
? overtimeSettings.value.publicHolidayFactor
: overtimeSettings.value.restDayFactor
const otPay = hours * hourlyRate * factor
workerTotalWage += otPay
row.push(hours.toFixed(2), otPay.toFixed(2))
} else {
row.push('0.00', '0.00')
}
clockInRecord = null
} else {
row.push('N/A', 'N/A')
}
allRows.push(row.join(','))
}
}
const summaryRow = [`${worker.full_name} Total`, '', '', '', '', workerTotalWage.toFixed(2)]
allRows.push(summaryRow.join(','))
allRows.push('')
}
if (allRows.length > 0) {
allRows.pop()
}
const now = new Date()
const timestamp = now.toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-')
const fileName = `${timestamp}_report.csv`
let csvContent = headers.join(',') + '\n' + allRows.join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', fileName)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const changeMonth = (offset) => {
const newDate = new Date(calendarDate.value)
newDate.setMonth(newDate.getMonth() + offset)
calendarDate.value = newDate
}
const toggleHoliday = (dateString) => {
const index = overtimeSettings.value.publicHolidays.indexOf(dateString)
if (index === -1) {
overtimeSettings.value.publicHolidays.push(dateString)
} else {
overtimeSettings.value.publicHolidays.splice(index, 1)
}
}
onMounted(() => {
fetchInitialData()
})
</script>
<style scoped>
/* No custom styles needed anymore, Tailwind handles everything */
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<div class="flex flex-col gap-8">
<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('createGeofence') }}</h2>
<div id="map-container" class="w-full h-96 rounded-lg border border-gray-300 dark:border-gray-600 mb-4 z-0">
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ $t('drawInstruction') }}</p>
<div class="flex flex-col sm:flex-row items-end gap-4">
<div class="flex-grow w-full">
<label for="geofence-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
$t('geofenceName') }}</label>
<input type="text" id="geofence-name" v-model="newGeofenceName" :placeholder="$t('geofenceNamePlaceholder')"
class="mt-1 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 w-full" />
</div>
<button @click="startOver" v-if="newGeofenceCoords"
class="bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto">
{{ $t('startOver') }}
</button>
<button @click="saveGeofence" :disabled="!canSave"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
{{ $t('saveGeofence') }}
</button>
</div>
</section>
<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('existingGeofences') }}</h2>
<div class="overflow-x-auto">
<table class="min-w-[600px] w-full text-left">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600">
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('name') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('status') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
{{ $t('actions') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="fence in geofences" :key="fence.id"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ fence.name }}</td>
<td class="px-4 py-3">
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase" :class="fence.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-200'
">
{{ $t(fence.is_active ? 'active' : 'inactive') }}
</span>
</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button @click="viewGeofenceOnMap(fence)"
class="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
<span class="text-base">🗺</span> {{ $t('view') }}
</button>
<button @click="toggleGeofenceStatus(fence)"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t(fence.is_active ? 'deactivate' : 'activate') }}
</button>
<button @click="deleteGeofence(fence.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('delete') }}
</button>
</td>
</tr>
<tr v-if="geofences.length === 0">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">{{ $t('noGeofencesFound') }}
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { apiFetch } from '@/api.js';
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw/dist/leaflet.draw.css';
import L from 'leaflet';
import 'leaflet-draw';
const geofences = ref([]);
const newGeofenceName = ref('');
const newGeofenceCoords = ref(null);
let map = null;
let drawnItems = null;
const fenceLayers = {};
const canSave = computed(() => newGeofenceName.value && newGeofenceCoords.value);
// START: Added Function
const startOver = () => {
drawnItems.clearLayers();
newGeofenceCoords.value = null;
newGeofenceName.value = '';
};
// END: Added Function
const fetchGeofences = async () => {
try {
geofences.value = await apiFetch('/api/managers/geofences');
displayGeofencesOnMap();
} catch (error) {
console.error('Failed to fetch geofences:', error);
}
};
const initMap = () => {
if (map) return;
map = L.map('map-container').setView([23.1291, 113.2644], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
drawnItems = new L.FeatureGroup();
map.addLayer(drawnItems);
const drawControl = new L.Control.Draw({
edit: { featureGroup: drawnItems, edit: false, remove: false },
draw: {
polygon: {
allowIntersection: false,
showArea: false
},
polyline: false, rectangle: false, circle: false, marker: false, circlemarker: false
}
});
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, (event) => {
drawnItems.clearLayers(); // Clear previous unsaved drawings
const layer = event.layer;
drawnItems.addLayer(layer);
const latLngs = layer.getLatLngs()[0];
const closedLatLngs = [...latLngs, latLngs[0]];
newGeofenceCoords.value = closedLatLngs.map(latlng => [latlng.lng, latlng.lat]);
});
};
const displayGeofencesOnMap = () => {
if (!map) return;
// Clear existing fence layers before redrawing
Object.values(fenceLayers).forEach(layer => map.removeLayer(layer));
geofences.value.forEach(fence => {
const leafletCoords = fence.coordinates.map(coord => [coord[1], coord[0]]);
const polygon = L.polygon(leafletCoords, {
color: fence.is_active ? '#3388ff' : '#888888',
weight: 3,
}).bindPopup(`<b>${fence.name}</b>`);
polygon.addTo(map);
fenceLayers[fence.id] = polygon;
});
};
const saveGeofence = async () => {
if (!canSave.value) return;
try {
const newFence = await apiFetch('/api/managers/geofences', {
method: 'POST',
body: JSON.stringify({ name: newGeofenceName.value, coordinates: newGeofenceCoords.value })
});
geofences.value.unshift(newFence);
startOver(); // Use startOver to clear the form
displayGeofencesOnMap();
} catch (error) {
console.error('Failed to save geofence:', error);
}
};
const deleteGeofence = async (id) => {
if (!confirm('Are you sure you want to delete this geofence?')) return;
try {
await apiFetch(`/api/managers/geofences/${id}`, { method: 'DELETE' });
if (fenceLayers[id]) {
map.removeLayer(fenceLayers[id]);
delete fenceLayers[id];
}
geofences.value = geofences.value.filter(g => g.id !== id);
} catch (error) {
console.error('Failed to delete geofence:', error);
}
};
const toggleGeofenceStatus = async (fence) => {
try {
const updatedFence = await apiFetch(`/api/managers/geofences/${fence.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: !fence.is_active })
});
const index = geofences.value.findIndex(f => f.id === fence.id);
if (index !== -1) {
geofences.value[index].is_active = updatedFence.is_active;
}
if (fenceLayers[fence.id]) {
fenceLayers[fence.id].setStyle({ color: updatedFence.is_active ? '#3388ff' : '#888888' });
}
} catch (error) {
console.error('Failed to toggle geofence status:', error);
}
};
const viewGeofenceOnMap = (fence) => {
if (fenceLayers[fence.id]) {
map.fitBounds(fenceLayers[fence.id].getBounds(), { padding: [50, 50] });
fenceLayers[fence.id].openPopup();
}
};
onMounted(() => {
initMap();
fetchGeofences();
});
onBeforeUnmount(() => {
if (map) {
map.remove();
map = null;
}
});
</script>
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="grid grid-cols-1 xl:grid-cols-4 gap-8 items-start">
<div class="xl:col-span-3 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">
{{ monthYear }}
</h2>
<div class="flex items-center gap-2">
<button @click="prevMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
</button>
<button @click="nextMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</button>
</div>
</div>
<div class="grid grid-cols-7 gap-1 text-center text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">
<div v-for="day in weekDays" :key="day" class="py-2">{{ day }}</div>
</div>
<div class="grid grid-cols-7 gap-1">
<div v-for="day in calendarGrid" :key="day.id"
@click="day.isCurrentMonth && onDayClick(day)"
:class="getDayClasses(day)">
{{ day.date }}
</div>
</div>
</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>
<div v-if="!hasPendingChanges" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ $t('noPendingChanges') }}
</div>
<div v-else class="space-y-4 max-h-80 overflow-y-auto pr-2">
<div v-if="datesToEnable.size > 0">
<h4 class="font-semibold text-green-600 dark:text-green-400 mb-2">{{ $t('datesToEnable') }}</h4>
<ul class="space-y-1">
<li v-for="date in sortedEnableList" :key="date" class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(date) }}
</li>
</ul>
</div>
<div v-if="datesToDisable.size > 0">
<h4 class="font-semibold text-red-600 dark:text-red-400 mb-2">{{ $t('datesToDisable') }}</h4>
<ul class="space-y-1">
<li v-for="date in sortedDisableList" :key="date" class="text-sm text-gray-700 dark:text-gray-300">
{{ formatDate(date) }}
</li>
</ul>
</div>
</div>
<div class="mt-6 flex flex-col sm:flex-row gap-3">
<button @click="applyChanges" :disabled="!hasPendingChanges" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('applyChanges') }}
</button>
<button @click="discardChanges" :disabled="!hasPendingChanges" class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ $t('discardChanges') }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { apiFetch } from '@/api.js';
const viewDate = ref(new Date());
const todayStr = new Date().toISOString().slice(0, 10);
const originalEnabledDates = ref(new Set());
const datesToEnable = ref(new Set());
const datesToDisable = ref(new Set());
const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0);
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' }));
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const calendarGrid = computed(() => {
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const grid = [];
for (let i = 0; i < firstDayOfMonth; i++) {
grid.push({ id: `prev-${i}`, isCurrentMonth: false });
}
for (let i = 1; i <= daysInMonth; i++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
grid.push({ id: dateStr, date: i, isCurrentMonth: true });
}
return grid;
});
const getDayClasses = (day) => {
if (!day.isCurrentMonth) return 'h-20';
const dateStr = day.id;
const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative'];
let isEnabled = originalEnabledDates.value.has(dateStr);
if (datesToEnable.value.has(dateStr)) isEnabled = true;
if (datesToDisable.value.has(dateStr)) isEnabled = false;
const isPendingEnable = datesToEnable.value.has(dateStr);
const isPendingDisable = datesToDisable.value.has(dateStr);
if (isPendingEnable) {
classes.push('bg-blue-500', 'text-white', 'font-bold');
} else if (isPendingDisable) {
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
classes.push('after:content-[\'\']', 'after:absolute', 'after:w-3/4', 'after:h-0.5', 'after:bg-red-500', 'after:left-1/2', 'after:top-1/2', 'after:-translate-x-1/2', 'after:-translate-y-1/2', 'after:rotate-[-10deg]');
} else if (isEnabled) {
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
} else {
classes.push('bg-white', 'dark:bg-gray-800', 'hover:bg-gray-100', 'dark:hover:bg-gray-700');
}
// Add a yellow ring for today's date
if (dateStr === todayStr) {
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
}
return classes;
};
function onDayClick(day) {
const dateStr = day.id;
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
if (isOriginallyEnabled) {
datesToDisable.value.has(dateStr)
? datesToDisable.value.delete(dateStr)
: datesToDisable.value.add(dateStr);
} else {
datesToEnable.value.has(dateStr)
? datesToEnable.value.delete(dateStr)
: datesToEnable.value.add(dateStr);
}
}
async function applyChanges() {
if (!confirm('Are you sure you want to apply these changes to the work schedule?')) return;
try {
await apiFetch('/api/managers/enabled-dates/update', {
method: 'POST',
body: JSON.stringify({
datesToEnable: Array.from(datesToEnable.value),
datesToDisable: Array.from(datesToDisable.value),
}),
});
await fetchEnabledDates();
discardChanges();
alert('Work schedule updated successfully!');
} catch (error) {
console.error('Failed to apply changes:', error);
alert('Failed to update schedule. Please try again.');
}
}
function discardChanges() {
datesToEnable.value.clear();
datesToDisable.value.clear();
}
const prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1));
const nextMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1));
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
async function fetchEnabledDates() {
try {
const dates = await apiFetch('/api/managers/enabled-dates');
originalEnabledDates.value = new Set(dates);
} catch (error) {
console.error('Failed to fetch enabled dates:', error);
}
}
onMounted(fetchEnabledDates);
</script>
+205 -511
View File
@@ -2,92 +2,56 @@
<div class="flex flex-col gap-8 pb-20"> <div class="flex flex-col gap-8 pb-20">
<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('addNewUser') }}</h2> <h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end"> <div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') <label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') }}</label>
}}</label> <input type="text" id="fullName" v-model="newWorker.fullName" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJohnSmith')" />
<input type="text" id="fullName" v-model="newWorker.fullName"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egJohnSmith')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('username') <label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username') }}</label>
}}</label> <input type="text" id="username" v-model="newWorker.username" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJsmith')" />
<input type="text" id="username" v-model="newWorker.username"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egJsmith')" />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('password') <label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password') }}</label>
}}</label> <input type="password" id="password" v-model="newWorker.password" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('eg123456')" />
<input type="password" id="password" v-model="newWorker.password" </div>
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" <div class="flex flex-col gap-2">
:placeholder="$t('eg123456')" /> <label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department') }}</label>
<input type="text" id="department" v-model="newWorker.department" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egSales')" />
</div>
<div class="flex flex-col gap-2">
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label>
<input type="text" id="position" v-model="newWorker.position" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egManager')" />
</div> </div>
<div class="flex flex-col justify-end"> <div class="flex flex-col justify-end">
<label class="flex items-center text-sm mb-2 cursor-pointer"> <label class="flex items-center text-sm mb-2 cursor-pointer">
<input type="checkbox" v-model="isManager" <input type="checkbox" v-model="isManager" class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
<span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span> <span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span>
</label> </label>
<button @click="addWorker" :disabled="!isFormValid || loading" <button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
{{ loading ? $t('adding') : $t('addUser') }} {{ loading ? $t('adding') : $t('addUser') }}
</button> </button>
</div> </div>
</div> </div>
<p v-if="errorMessage" class="text-red-500 text-sm mt-4">{{ errorMessage }}</p> <p v-if="errorMessage" class="text-red-500 text-sm mt-4">{{ errorMessage }}</p>
</section> </section>
<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('manageTags') }}</h2>
<div class="flex flex-col sm:flex-row items-end gap-4 mb-4">
<div class="flex flex-col gap-2 flex-grow w-full sm:w-auto">
<label for="new-tag" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('createNewTag')
}}</label>
<input type="text" id="new-tag" v-model="newTagName" @keyup.enter="createTag"
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
:placeholder="$t('egTeam')" />
</div>
<button @click="createTag" :disabled="!newTagName"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
{{ $t('createTag') }}
</button>
</div>
<div class="flex flex-wrap gap-2 min-h-[2rem]">
<span v-for="tag in allTags" :key="tag.id"
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
{{ tag.tag_name }}
<button @click="deleteTag(tag.id)" class="text-red-500 hover:text-red-700 ml-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
</span>
</div>
</section>
<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:gap-0 sm:items-center justify-between"> <div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between">
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrUsername')" <input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs 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 sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" /> <div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-col gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mr-1">{{ $t('filterByTag') }}</span> <label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
<button v-for="tag in allTags" :key="tag.id" @click="toggleTagFilter(tag.id)" :class="{ <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" />
'bg-blue-600 text-white': selectedTagIds.includes(tag.id), </div>
'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600': <div class="flex flex-col gap-2">
!selectedTagIds.includes(tag.id), <label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
}" class="px-3 py-1 rounded-full text-sm font-medium transition-colors duration-200"> <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" />
{{ tag.tag_name }} </div>
</button> <button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="self-end bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
<button v-if="selectedTagIds.length > 0" @click="clearTagFilter" {{ exportLoading ? $t('exporting') : $t('exportAll') }}
class="text-blue-600 hover:text-blue-800 text-sm font-medium ml-2"> </button>
{{ $t('clearFilter') }}
</button>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -95,529 +59,259 @@
<thead class="bg-gray-50 dark:bg-gray-700"> <thead class="bg-gray-50 dark:bg-gray-700">
<tr class="border-b border-gray-200 dark:border-gray-600"> <tr class="border-b border-gray-200 dark:border-gray-600">
<th class="w-12 px-2 py-3 text-center"> <th class="w-12 px-2 py-3 text-center">
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" <input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('fullName') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('username') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('tags') }}
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
{{ $t('dateJoined') }}
</th>
<th
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
{{ $t('actions') }}
</th> </th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('fullName') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700"> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="worker in workers" :key="worker.id" <tr v-for="worker in workers" :key="worker.id" :class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }" class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
<td class="px-2 py-3 text-center"> <td class="px-2 py-3 text-center">
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" <input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
</td> </td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
<td class="px-4 py-3"> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.department }}</td>
<template v-if="worker.tags"> <td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.position }}</td>
<span v-for="tag in worker.tags.split(', ')" :key="tag" <td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
class="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-2 py-1 rounded-full text-xs font-medium mr-1 mb-1 inline-block">
{{ tag }}
</span>
</template>
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">
{{ new Date(worker.created_at).toLocaleDateString() }}
</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap"> <td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<button @click="openTagEditor(worker)" <button @click="openPasswordModal(worker)" class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('password') }}</button>
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200"> <button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button>
{{ $t('editTags') }} <button @click="clearDevice(worker.id)" class="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('clearDevice') }}</button>
</button> <button @click="deleteWorker(worker.id)" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('delete') }}</button>
<button @click="openPasswordModal(worker)"
class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('password') }}
</button>
<button @click="viewRecords(worker.id)"
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('viewRecords') }}
</button>
<button @click="deleteWorker(worker.id)"
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
{{ $t('delete') }}
</button>
</td> </td>
</tr> </tr>
<tr v-if="workers.length === 0"> <tr v-if="workers.length === 0">
<td colspan="6" class="text-center py-8 text-gray-500 dark:text-gray-400"> <td colspan="7" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }} {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div v-if="totalPages > 1" <div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" <span class="text-gray-700 dark:text-gray-200">{{ $t('pageOf', { current: currentPage, total: totalPages }) }}</span>
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white"> <button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
{{ $t('previous') }}
</button>
<span class="text-gray-700 dark:text-gray-200">
{{ $t('pageOf', { current: currentPage, total: totalPages }) }}
</span>
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
{{ $t('next') }}
</button>
</div> </div>
</section> </section>
<div v-if="isEditingTags" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4"> <div v-if="isPasswordModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md transform transition-all scale-100 opacity-100">
<h3 class="text-2xl font-bold mb-6 text-gray-800 dark:text-white">{{ editorTitle }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<label v-for="tag in allTags" :key="tag.id"
class="flex items-center gap-3 cursor-pointer text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 p-3 rounded-lg transition-all duration-200 ease-in-out border border-transparent hover:border-blue-300 dark:hover:border-blue-700">
<div class="relative flex items-center">
<input type="checkbox" :checked="isTagAppliedToSelection(tag.id)" @change="toggleTagForSelection(tag.id)"
class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-400 text-blue-600 transition-all checked:border-blue-600 checked:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-500 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:focus:ring-blue-400" />
<span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 transition-opacity peer-checked:opacity-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"
stroke="currentColor" stroke-width="1">
<path fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd" />
</svg>
</span>
</div>
<span class="text-base font-medium select-none">{{ tag.tag_name }}</span>
</label>
<p v-if="allTags.length === 0">
{{ $t('noTagsAvailable') }}
</p>
</div>
<button @click="closeTagEditor"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2 rounded-md mt-8 w-full transition-colors duration-200">
{{ $t('done') }}
</button>
</div>
</div>
<div v-if="isPasswordModalVisible"
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md"> <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">{{ $t('changePassword') }}</h3> <h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">{{ $t('changePassword') }}</h3>
<p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300"> <p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300">
For user: <span class="font-semibold">{{ $t('forUser') }}: ...</span> {{ $t('forUser') }}: <span class="font-semibold">{{ editingWorkerPassword.full_name }}</span>
</p> </p>
<form @submit.prevent="updateWorkerPassword"> <form @submit.prevent="updateWorkerPassword">
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div> <div>
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ <label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('newPassword') }}</label>
$t('newPassword') }}</label> <input type="password" id="newPassword" v-model="newPassword" required 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-700 text-gray-900 dark:text-white" />
<input type="password" id="newPassword" v-model="newPassword" required
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-700 text-gray-900 dark:text-white" />
</div> </div>
<div> <div>
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ <label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('confirmNewPassword') }}</label>
$t('confirmNewPassword') }}</label> <input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required 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-700 text-gray-900 dark:text-white" />
<input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required
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-700 text-gray-900 dark:text-white" />
</div> </div>
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2"> <p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">{{ passwordErrorMessage }}</p>
{{ passwordErrorMessage }} <p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">{{ passwordSuccessMessage }}</p>
</p>
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">
{{ passwordSuccessMessage }}
</p>
</div> </div>
<div class="flex justify-end gap-4 mt-8"> <div class="flex justify-end gap-4 mt-8">
<button type="button" @click="closePasswordModal" <button type="button" @click="closePasswordModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors">{{ $t('cancel') }}</button>
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors"> <button type="submit" :disabled="passwordLoading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
{{ $t('cancel') }}
</button>
<button type="submit" :disabled="passwordLoading"
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
{{ passwordLoading ? $t('saving') : $t('savePassword') }} {{ passwordLoading ? $t('saving') : $t('savePassword') }}
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div v-if="selectedWorkerIds.length > 0"
class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg py-4 px-8 flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-6 z-40">
<span class="font-medium text-gray-800 dark:text-white">{{ selectedWorkerIds.length }} worker(s) selected</span>
<button @click="openBulkTagEditor"
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
{{ $t('bulkEditTags') }}
</button>
<button @click="selectedWorkerIds = []"
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
{{ $t('clearSelection') }}
</button>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed, watch } from 'vue' import { ref, onMounted, computed, watch } from 'vue';
import { useRouter } from 'vue-router' import { apiFetch } from '@/api.js';
import { apiFetch } from '@/api.js' import { useRouter } from 'vue-router';
// --- STATE --- const router = useRouter();
const router = useRouter()
const workers = ref([])
const loading = ref(false)
const errorMessage = ref('')
const newWorker = ref({ fullName: '', username: '', password: '' })
const isManager = ref(false)
const allTags = ref([])
const newTagName = ref('')
const editingWorker = ref(null)
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const totalWorkers = ref(0)
const selectedTagIds = ref([])
const isEditingTags = ref(false)
const selectedWorkerIds = ref([])
// NEW: State for the password change modal
const isPasswordModalVisible = ref(false)
const editingWorkerPassword = ref(null)
const newPassword = ref('')
const confirmNewPassword = ref('')
const passwordErrorMessage = ref('')
const passwordSuccessMessage = ref('')
const passwordLoading = ref(false)
// --- COMPUTED ---
// ... existing computed properties ...
const isFormValid = computed(
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password,
)
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value))
const isAllSelected = computed(() => {
return workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
})
const editorTitle = computed(() => {
if (editingWorker.value) {
return `Edit Tags for ${editingWorker.value.full_name}`
}
if (selectedWorkerIds.value.length > 0) {
return `Editing Tags for ${selectedWorkerIds.value.length} Workers`
}
return 'Edit Tags'
})
// --- WATCHERS ---
// ... existing watchers ...
let debounceTimer = null
const debouncedFetch = () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
fetchWorkers(1)
}, 500)
}
watch(searchQuery, debouncedFetch)
watch(
selectedTagIds,
() => {
fetchWorkers(1)
},
{ deep: true },
)
watch(currentPage, () => {
selectedWorkerIds.value = []
})
// --- METHODS ---
// ... existing methods ...
const fetchInitialData = async () => {
loading.value = true
try {
const [workersData, tagsData] = await Promise.all([
apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=1&limit=${pageSize.value}`),
apiFetch('/api/managers/tags'),
])
workers.value = workersData.workers
totalWorkers.value = workersData.totalCount
allTags.value = tagsData
} catch (err) {
errorMessage.value = $t('failedToLoadPageData')
console.error(err)
} finally {
loading.value = false
}
}
const fetchWorkers = async (page = currentPage.value) => {
loading.value = true
selectedWorkerIds.value = []
try {
const tagsQueryParam =
selectedTagIds.value.length > 0 ? `&tags=${selectedTagIds.value.join(',')}` : ''
const url = `/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}${tagsQueryParam}`
const data = await apiFetch(url)
workers.value = data.workers
totalWorkers.value = data.totalCount
currentPage.value = page
} catch (err) {
errorMessage.value = $t('failedToFetchWorkers')
console.error(err)
} finally {
loading.value = false
}
}
const changePage = (page) => {
if (page > 0 && page <= totalPages.value) {
fetchWorkers(page)
}
}
const addWorker = async () => {
if (!isFormValid.value) return
loading.value = true
errorMessage.value = ''
try {
const payload = {
...newWorker.value,
role: isManager.value ? 'manager' : 'worker',
}
await apiFetch('/api/managers/workers', {
method: 'POST',
body: JSON.stringify(payload),
})
await fetchWorkers(1)
newWorker.value = { fullName: '', username: '', password: '' }
isManager.value = false
} catch (err) {
errorMessage.value = err.message || $t('errorAddingUser')
console.error(err)
} finally {
loading.value = false
}
}
const deleteWorker = async (id) => {
if (!confirm($t('areYouSureDeleteWorker'))) return
try {
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' })
if (workers.value.length === 1 && currentPage.value > 1) {
await fetchWorkers(currentPage.value - 1)
} else {
await fetchWorkers(currentPage.value)
}
} catch (err) {
errorMessage.value = $t('failedToDeleteWorker')
console.error(err)
}
}
const viewRecords = (workerId) => { const viewRecords = (workerId) => {
router.push(`/manager/attendance/${workerId}`) router.push(`/manager/attendance/${workerId}`);
} };
const createTag = async () => { // --- STATE ---
if (!newTagName.value) return const workers = ref([]);
const loading = ref(false);
const errorMessage = ref('');
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
const isManager = ref(false);
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(20);
const totalWorkers = ref(0);
const selectedWorkerIds = ref([]);
const isPasswordModalVisible = ref(false);
const editingWorkerPassword = ref(null);
const newPassword = ref('');
const confirmNewPassword = ref('');
const passwordErrorMessage = ref('');
const passwordSuccessMessage = ref('');
const passwordLoading = ref(false);
const exportFilters = ref({ startDate: '', endDate: '' });
const exportLoading = ref(false);
// --- COMPUTED ---
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value));
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
// --- WATCHERS ---
watch(searchQuery, () => fetchWorkers(1));
watch(currentPage, () => selectedWorkerIds.value = []);
// --- METHODS ---
const fetchWorkers = async (page = currentPage.value) => {
loading.value = true;
try { try {
const newTag = await apiFetch('/api/managers/tags', { const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
workers.value = data.workers;
totalWorkers.value = data.totalCount;
currentPage.value = page;
} catch (_err) {
errorMessage.value = 'Failed to fetch workers.';
} finally {
loading.value = false;
}
};
const changePage = (page) => {
if (page > 0 && page <= totalPages.value) fetchWorkers(page);
};
const addWorker = async () => {
if (!isFormValid.value) return;
loading.value = true;
errorMessage.value = '';
try {
await apiFetch('/api/managers/workers', {
method: 'POST', method: 'POST',
body: JSON.stringify({ tag_name: newTagName.value }), body: JSON.stringify({ ...newWorker.value, role: isManager.value ? 'manager' : 'worker' }),
}) });
allTags.value.push(newTag) await fetchWorkers(1);
newTagName.value = '' newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
} catch (err) { isManager.value = false;
alert(err.message) } catch (_err) {
errorMessage.value = _err.message || 'Error adding user.';
} finally {
loading.value = false;
} }
} };
const deleteTag = async (tagId) => { const deleteWorker = async (id) => {
if (!confirm($t('areYouSureDeleteTag'))) return if (!confirm('Are you sure you want to delete this worker?')) return;
return
try { try {
await apiFetch(`/api/managers/tags/${tagId}`, { method: 'DELETE' }) await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
allTags.value = allTags.value.filter((tag) => tag.id !== tagId) fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
// Also re-fetch workers to update their tag display if any had this tag } catch (_err) {
fetchWorkers(currentPage.value) errorMessage.value = 'Failed to delete worker.';
alert($t('tagDeleted'))
} catch (err) {
alert(err.message || $t('failedToDeleteTag'))
console.error(err)
} }
} };
const openTagEditor = (worker) => {
editingWorker.value = worker
isEditingTags.value = true
}
const openBulkTagEditor = () => {
editingWorker.value = null
isEditingTags.value = true
}
const closeTagEditor = () => {
isEditingTags.value = false
editingWorker.value = null
selectedWorkerIds.value = []
fetchWorkers(currentPage.value)
}
const isTagAppliedToSelection = (tagId) => {
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
if (!tagName) return false
const targetWorkers = editingWorker.value
? [editingWorker.value]
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
if (targetWorkers.length === 0) return false
return targetWorkers.every((worker) => worker.tags && worker.tags.split(', ').includes(tagName))
}
const toggleTagForSelection = async (tagId) => {
const workersToUpdate = editingWorker.value
? [editingWorker.value]
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
if (workersToUpdate.length === 0) return
const isAdding = !isTagAppliedToSelection(tagId)
const clearDevice = async (workerId) => {
if (!confirm('Are you sure you want to clear the registered device for this worker?')) return;
try { try {
for (const worker of workersToUpdate) { await apiFetch(`/api/managers/workers/${workerId}/reset-device`, { method: 'PUT' });
if (isAdding) { alert('Worker device cleared successfully.');
await apiFetch(`/api/managers/workers/${worker.id}/tags`, { } catch (_err) {
method: 'POST', alert(_err.message || 'Failed to clear device.');
body: JSON.stringify({ tagId: tagId }),
})
} else {
await apiFetch(`/api/managers/workers/${worker.id}/tags/${tagId}`, { method: 'DELETE' })
}
}
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
if (tagName) {
workersToUpdate.forEach((worker) => {
const tags = worker.tags ? worker.tags.split(', ').filter((t) => t) : []
if (isAdding) {
if (!tags.includes(tagName)) tags.push(tagName)
} else {
const index = tags.indexOf(tagName)
if (index > -1) tags.splice(index, 1)
}
worker.tags = tags.join(', ')
})
}
} catch (err) {
alert($t('failedToUpdateTags'))
console.error(err)
} }
} };
// NEW: Methods for password change modal
const openPasswordModal = (worker) => { const openPasswordModal = (worker) => {
editingWorkerPassword.value = worker editingWorkerPassword.value = worker;
isPasswordModalVisible.value = true isPasswordModalVisible.value = true;
} };
const closePasswordModal = () => { const closePasswordModal = () => {
isPasswordModalVisible.value = false isPasswordModalVisible.value = false;
editingWorkerPassword.value = null editingWorkerPassword.value = null;
newPassword.value = '' newPassword.value = '';
confirmNewPassword.value = '' confirmNewPassword.value = '';
passwordErrorMessage.value = '' passwordErrorMessage.value = '';
passwordSuccessMessage.value = '' passwordSuccessMessage.value = '';
passwordLoading.value = false passwordLoading.value = false;
} };
const updateWorkerPassword = async () => { const updateWorkerPassword = async () => {
passwordErrorMessage.value = '' passwordErrorMessage.value = '';
passwordSuccessMessage.value = ''
if (newPassword.value !== confirmNewPassword.value) { if (newPassword.value !== confirmNewPassword.value) {
passwordErrorMessage.value = 'Passwords do not match.' passwordErrorMessage.value = 'Passwords do not match.';
return return;
} }
if (newPassword.value.length < 6) { if (newPassword.value.length < 6) {
passwordErrorMessage.value = 'Password must be at least 6 characters long.' passwordErrorMessage.value = 'Password must be at least 6 characters long.';
return return;
} }
if (!editingWorkerPassword.value) return passwordLoading.value = true;
passwordLoading.value = true
try { try {
await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, { await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ newPassword: newPassword.value }), body: JSON.stringify({ newPassword: newPassword.value }),
}) });
passwordSuccessMessage.value = 'Password updated successfully!' passwordSuccessMessage.value = 'Password updated successfully!';
setTimeout(closePasswordModal, 2000) // Close modal after 2 seconds setTimeout(closePasswordModal, 2000);
} catch (err) { } catch (_err) {
passwordErrorMessage.value = err.message || 'Failed to update password.' passwordErrorMessage.value = _err.message || 'Failed to update password.';
console.error(err)
} finally { } finally {
passwordLoading.value = false passwordLoading.value = false;
} }
} };
const isWorkerSelected = (workerId) => { const isWorkerSelected = (workerId) => selectedWorkerIds.value.includes(workerId);
return selectedWorkerIds.value.includes(workerId)
}
const toggleWorkerSelection = (workerId) => { const toggleWorkerSelection = (workerId) => {
const index = selectedWorkerIds.value.indexOf(workerId) const index = selectedWorkerIds.value.indexOf(workerId);
if (index === -1) { if (index === -1) selectedWorkerIds.value.push(workerId);
selectedWorkerIds.value.push(workerId) else selectedWorkerIds.value.splice(index, 1);
} else { };
selectedWorkerIds.value.splice(index, 1)
}
}
const toggleSelectAll = (event) => { const toggleSelectAll = (event) => {
if (event.target.checked) { selectedWorkerIds.value = event.target.checked ? workers.value.map(w => w.id) : [];
selectedWorkerIds.value = workers.value.map((w) => w.id) };
} else {
selectedWorkerIds.value = []
}
}
const toggleTagFilter = (tagId) => { const exportWorkHours = async () => {
const index = selectedTagIds.value.indexOf(tagId) exportLoading.value = true;
if (index === -1) { const { startDate, endDate } = exportFilters.value;
selectedTagIds.value = [...selectedTagIds.value, tagId] let workerIds = selectedWorkerIds.value.join(',');
} else {
selectedTagIds.value = selectedTagIds.value.filter((id) => id !== tagId)
}
}
const clearTagFilter = () => { try {
selectedTagIds.value = [] const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
} headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
});
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 = `work_hours_${startDate}_to_${endDate}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_err) {
alert('Failed to export records.');
} finally {
exportLoading.value = false;
}
};
onMounted(() => { onMounted(() => {
fetchInitialData() fetchWorkers();
}) });
</script> </script>
<style scoped>
/* No custom styles needed anymore, Tailwind handles everything */
</style>
+8 -8
View File
@@ -117,8 +117,8 @@ const fetchQrCodes = async () => {
// CORRECT: Get the data directly from apiFetch // CORRECT: Get the data directly from apiFetch
const data = await apiFetch('/api/managers/qr-codes') const data = await apiFetch('/api/managers/qr-codes')
qrCodes.value = data qrCodes.value = data
} catch (err) { } catch (_err) {
console.error('Failed to fetch QR codes:', err) console.error('Failed to fetch QR codes:',_err)
} }
} }
@@ -144,8 +144,8 @@ const addQrCode = async () => {
if (error) console.error(error) if (error) console.error(error)
}, },
) )
} catch (err) { } catch (_err) {
console.error('Failed to add QR code:', err) console.error('Failed to add QR code:',_err)
} }
} }
@@ -162,8 +162,8 @@ const toggleQrStatus = async (qr) => {
if (index !== -1) { if (index !== -1) {
qrCodes.value[index].is_active = !qrCodes.value[index].is_active qrCodes.value[index].is_active = !qrCodes.value[index].is_active
} }
} catch (err) { } catch (_err) {
console.error('Failed to update QR status:', err) console.error('Failed to update QR status:',_err)
} }
} }
@@ -178,8 +178,8 @@ const deleteQrCode = async (id) => {
}) })
// Filter out the deleted QR code on success // Filter out the deleted QR code on success
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id) qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
} catch (err) { } catch (_err) {
console.error('Failed to delete QR code:', err) console.error('Failed to delete QR code:',_err)
} }
} }
+187
View File
@@ -0,0 +1,187 @@
<template>
<div class="flex flex-col gap-8 pb-20">
<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('failedClockSummary') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="flex-grow">
<label for="search-worker" class="sr-only">{{ $t('searchByNameOrDepartment') }}</label>
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div class="flex items-center gap-4 flex-wrap">
<div class="flex flex-col">
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div class="flex flex-col">
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<button @click="fetchFailedRecords" :disabled="loadingReport" class="self-end bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-[700px] w-full text-left">
<thead class="bg-gray-100 dark:bg-gray-700">
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer" @click="sortBy('full_name')">
{{ $t('worker') }}
<span v-if="sortField === 'full_name'" class="ml-1">{{ sortDirection === 'asc' ? '' : '' }}</span>
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center" @click="sortBy('count')">
{{ $t('failedCount') }}
<span v-if="sortField === 'count'" class="ml-1">{{ sortDirection === 'asc' ? '' : '' }}</span>
</th>
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">{{ $t('actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr v-for="record in sortedFailedRecords" :key="record.worker_id" class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">{{ record.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">{{ record.count }}</td>
<td class="px-4 py-3 text-center">
<button @click="showDetails(record.worker_id, record.full_name)" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ $t('viewDetails') }}
</button>
</td>
</tr>
<tr v-if="failedRecords.length === 0 && !loadingReport">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400 italic">
{{ $t('noRecordsFound') }}
</td>
</tr>
<tr v-if="loadingReport">
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
<div class="flex justify-center items-center">
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ $t('loadingReport') }}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Details Modal -->
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-4 border-b pb-3">
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">{{ detailModalTitle }}</h3>
<button @click="showDetailModal = false" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
&times;
</button>
</div>
<div class="details-content overflow-y-auto flex-grow">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('timestamp') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('eventType') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('location') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('notes') }}</th>
</tr>
</thead>
<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">{{ new Date(detail.timestamp).toLocaleString() }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
{{ $t(detail.event_type) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.qrCodeUsedName || $t('nA') }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.notes || $t('nA') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiFetch } from '@/api.js';
const { t: $t } = useI18n();
// --- STATE ---
const searchQuery = ref('');
const filters = ref({ startDate: '', endDate: '' });
const loadingReport = ref(false);
const failedRecords = ref([]);
const showDetailModal = ref(false);
const detailRecords = ref([]);
const detailModalTitle = ref('');
const sortField = ref('count');
const sortDirection = ref('desc');
// --- COMPUTED ---
const sortedFailedRecords = computed(() => {
return [...failedRecords.value].sort((a, b) => {
const modifier = sortDirection.value === 'asc' ? 1 : -1;
if (a[sortField.value] < b[sortField.value]) return -1 * modifier;
if (a[sortField.value] > b[sortField.value]) return 1 * modifier;
return 0;
});
});
// --- METHODS ---
const fetchInitialData = async () => {
const today = new Date();
filters.value.endDate = today.toISOString().split('T')[0];
const twoMonthsAgo = new Date();
twoMonthsAgo.setMonth(today.getMonth() - 2);
filters.value.startDate = twoMonthsAgo.toISOString().split('T')[0];
await fetchFailedRecords();
};
const fetchFailedRecords = async () => {
loadingReport.value = true;
failedRecords.value = [];
try {
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
failedRecords.value = await apiFetch(url);
} catch (_err) {
console.error('Failed to fetch failed records',_err);
alert('Failed to fetch records.');
} finally {
loadingReport.value = false;
}
};
const sortBy = (field) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
} else {
sortField.value = field;
sortDirection.value = 'asc';
}
};
const showDetails = async (workerId, workerName) => {
try {
detailModalTitle.value = `${$t('failedRecordsFor')} ${workerName}`;
const url = `/api/managers/failed-records/details?workerId=${workerId}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
detailRecords.value = await apiFetch(url);
showDetailModal.value = true;
} catch (_err) {
console.error('Failed to fetch details',_err);
alert('Failed to load details.');
}
};
watch(searchQuery, fetchFailedRecords);
onMounted(() => {
fetchInitialData();
});
</script>
+59 -6
View File
@@ -10,6 +10,7 @@
"invalidToken": "Invalid token received from server.", "invalidToken": "Invalid token received from server.",
"english": "English", "english": "English",
"malay": "Malay", "malay": "Malay",
"setting": "Setting",
"yourStatus": "Your Status", "yourStatus": "Your Status",
"clockedIn": "Clocked In", "clockedIn": "Clocked In",
@@ -24,19 +25,20 @@
"out": "Out", "out": "Out",
"cancel": "Cancel", "cancel": "Cancel",
"clockHistory": "Clock History",
"viewMyClockHistory": "View My Clock History", "viewMyClockHistory": "View My Clock History",
"changeMyPassword": "Change My Password", "changeMyPassword": "Change My Password",
"updateYourPassword": "Update Your Password",
"myClockHistory": "My Clock History", "myClockHistory": "My Clock History",
"backToDashboard": "Back to Dashboard", "backToDashboard": "Back to Dashboard",
"noClockHistory": "You have no clocking history.", "noClockHistory": "You have no clocking history.",
"clockHistoryFetchFail": "Failed to fetch clock history:", "clockHistoryFetchFail": "Failed to fetch clock history:",
"viewClockHistory": "View My Clock History", "viewClockHistory": "View My Clock History",
"changePassword": "Change My Password", "changePassword": "Change My Password",
"successClockIn": "Successfully clocked in.", "successClockIn": "Successfully clocked in.",
"successClockOut": "Successfully clocked out.", "successClockOut": "Successfully clocked out.",
"qrFail": "Could not detect a QR code. Please try again.", "qrFail": "Could not detect a QR code. Please try again.",
"geoFail": "Unable to retrieve your location: {message}. Please enable location services.",
"successClock": "Successfully clocked at {location}.", "successClock": "Successfully clocked at {location}.",
"changePasswordTitle": "Change Password", "changePasswordTitle": "Change Password",
"currentPassword": "Current Password", "currentPassword": "Current Password",
@@ -46,6 +48,14 @@
"tabPersonnel": "Personnel", "tabPersonnel": "Personnel",
"tabAttendance": "Attendance", "tabAttendance": "Attendance",
"tabWarning": "Warnings",
"warningSettings": "Warning Settings",
"failedClockSummary": "Failed Clock Summary",
"failedCount": "Failed Count",
"viewDetails": "View Details",
"fetchRecords": "Fetch Records",
"failedRecordsFor": "Failed Records for ",
"eventType": "Event Type",
"tabQrCodes": "QR Codes", "tabQrCodes": "QR Codes",
"uploadQrImage": "Upload QR Image", "uploadQrImage": "Upload QR Image",
@@ -58,7 +68,7 @@
"tryAgain": "Try Again", "tryAgain": "Try Again",
"qrDetectedGettingLocation": "QR Code detected. Getting location...", "qrDetectedGettingLocation": "QR Code detected. Getting location...",
"geolocationNotSupported": "Geolocation is not supported by your browser.", "geolocationNotSupported": "Geolocation is not supported by your browser.",
"unableToRetrieveLocation": "Unable to retrieve your location: {message}. Please enable location services.", "unableToRetrieveLocation": "Unable to retrieve your location. Please check location permissions. (Details: {message})",
"qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.", "qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.",
"updatePassword": "Update Password", "updatePassword": "Update Password",
"passwordsNoMatch": "New passwords do not match.", "passwordsNoMatch": "New passwords do not match.",
@@ -118,9 +128,13 @@
"tagLoadError": "Could not load workers for the selected tag.", "tagLoadError": "Could not load workers for the selected tag.",
"generateReportError": "Please select workers, set valid date range, and enter a salary.", "generateReportError": "Please select workers, set valid date range, and enter a salary.",
"reportGenerationError": "An error occurred while generating the report.", "reportGenerationError": "An error occurred while generating the report.",
"exportAll": "Export All",
"export": "Export",
"addNewUser": "Add New User", "addNewUser": "Add New User",
"fullName": "Full Name", "fullName": "Full Name",
"department": "Department",
"position": "Position",
"egJohnSmith": "e.g. John Smith", "egJohnSmith": "e.g. John Smith",
"egJsmith": "e.g. jsmith", "egJsmith": "e.g. jsmith",
"eg123456": "e.g. 123456", "eg123456": "e.g. 123456",
@@ -130,16 +144,20 @@
"manageTags": "Manage Tags", "manageTags": "Manage Tags",
"createNewTag": "Create New Tag", "createNewTag": "Create New Tag",
"egTeam": "e.g. Team", "egTeam": "e.g. Team",
"egSales": "e.g. Sales",
"egManager": "e.g. Manager",
"createTag": "Create Tag", "createTag": "Create Tag",
"tags": "Tags", "tags": "Tags",
"workerRoster": "Worker Roster", "workerRoster": "Worker Roster",
"searchByNameOrUsername": "Search by name or username", "searchByNameOrUsername": "Search by name or username",
"searchByNameOrDepartment": "Search by name or department",
"filterByTag": "Filter by tag", "filterByTag": "Filter by tag",
"clearFilter": "Clear filter", "clearFilter": "Clear filter",
"dateJoined": "Date Joined", "dateJoined": "Date Joined",
"actions": "Actions", "actions": "Actions",
"editTags": "Edit Tags", "editTags": "Edit Tags",
"viewRecords": "View Records", "viewRecords": "View Records",
"clearDevice": "Clear Device",
"delete": "Delete", "delete": "Delete",
"loadingWorkers": "Loading workers...", "loadingWorkers": "Loading workers...",
"noWorkersFound": "No workers found.", "noWorkersFound": "No workers found.",
@@ -180,5 +198,40 @@
"download": "Download", "download": "Download",
"noQrCodesFound": "No QR codes found. Create one above!", "noQrCodesFound": "No QR codes found. Create one above!",
"deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.", "deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.",
"qrDownloadError": "Sorry, the QR code could not be downloaded." "qrDownloadError": "Sorry, the QR code could not be downloaded.",
} "loading": "Loading...",
"tabGeofencing": "Geofencing",
"createGeofence": "Create Geofence",
"drawInstruction": "Click the polygon tool on the map to start drawing a new geofence. Click the first point to finish.",
"geofenceName": "Geofence Name",
"geofenceNamePlaceholder": "e.g., Main Warehouse Zone",
"saveGeofence": "Save Geofence",
"existingGeofences": "Existing Geofences",
"view": "View",
"noGeofencesFound": "No Geofences Found",
"startOver" : "Start Over",
"workScheduleTitle": "Work Schedule",
"workScheduleDescription": "Click on a date to toggle its status. Enabled days are green. Changes will not be saved until you click 'Apply Changes'.",
"pendingChanges": "Pending Changes",
"noPendingChanges": "Click on the calendar to enable or disable dates.",
"datesToEnable": "Enable these dates:",
"datesToDisable": "Disable these dates:",
"applyChanges": "Apply Changes",
"discardChanges": "Discard Changes",
"statusClockedIn": "You are Clocked In",
"statusClockedOut": "You are Clocked Out",
"scanToClockIn": "Scan QR Code to Clock In",
"scanToClockOut": "Scan QR Code to Clock Out",
"error.default": "An unexpected error occurred. Please try again.",
"error.clockingDisabled": "Clocking is disabled for today. Your attempt has been logged.",
"error.noActiveGeofence": "Clocking failed: No active work area is defined on the server.",
"error.outsideGeofence": "Clocking failed: You are outside the designated work area by {distance}m.",
"error.invalidQrCode": "Clocking failed: The scanned QR Code is invalid or no longer active.",
"error.alreadyClockedIn": "Action failed: You are already clocked in.",
"error.alreadyClockedOut": "Action failed: You are already clocked out.",
"error.criticalServer": "A critical server error occurred. Please contact support."
}
+61 -7
View File
@@ -1,5 +1,5 @@
{ {
"appTitle": "Sistem Masuk/Keluar Kerja", "appTitle": "Sistem Kehadiran",
"logout": "Log Keluar", "logout": "Log Keluar",
"login": "Log Masuk", "login": "Log Masuk",
"username": "Nama Pengguna", "username": "Nama Pengguna",
@@ -10,7 +10,8 @@
"invalidToken": "Token tidak sah diterima dari pelayan.", "invalidToken": "Token tidak sah diterima dari pelayan.",
"english": "Bahasa Inggeris", "english": "Bahasa Inggeris",
"malay": "Bahasa Melayu", "malay": "Bahasa Melayu",
"setting": "Tetapan",
"yourStatus": "Status Anda", "yourStatus": "Status Anda",
"clockedIn": "Sudah Masuk", "clockedIn": "Sudah Masuk",
"clockedOut": "Sudah Keluar", "clockedOut": "Sudah Keluar",
@@ -24,14 +25,16 @@
"out": "Keluar", "out": "Keluar",
"cancel": "Batal", "cancel": "Batal",
"clockHistroy": "Sejarah Kehadiran",
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya", "viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
"changeMyPassword": "Tukar Kata Laluan Saya", "changeMyPassword": "Tukar Kata Laluan Saya",
"updateYourPassword": "Tukar Kata Laluan Anda",
"myClockHistory": "Sejarah Kehadiran Saya", "myClockHistory": "Sejarah Kehadiran Saya",
"backToDashboard": "Kembali ke Papan Pemuka", "backToDashboard": "Kembali ke Papan Pemuka",
"noClockHistory": "Tiada rekod kehadiran.", "noClockHistory": "Tiada rekod kehadiran.",
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:", "clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
"viewClockHistory": "Lihat Sejarah Kehadiran Saya", "viewClockHistory": "Lihat Sejarah Kehadiran Saya",
"changePassword": "Tukar Kata Laluan Saya", "changePassword": "Tukar Kata Laluan Saya",
"successClockIn": "Berjaya masuk kerja.", "successClockIn": "Berjaya masuk kerja.",
"successClockOut": "Berjaya keluar kerja.", "successClockOut": "Berjaya keluar kerja.",
@@ -46,6 +49,14 @@
"tabPersonnel": "Personel", "tabPersonnel": "Personel",
"tabAttendance": "Kehadiran", "tabAttendance": "Kehadiran",
"tabWarning": "Amaran",
"warningSettings": "Tetapan Amaran",
"failedClockSummary": "Ringkasan Kegagalan Clock",
"failedCount": "Bilangan Gagal",
"viewDetails": "Lihat Butiran",
"fetchRecords": "Dapatkan Rekod",
"failedRecordsFor": "Rekod Gagal untuk ",
"eventType": "Jenis Peristiwa",
"tabQrCodes": "Kod QR", "tabQrCodes": "Kod QR",
"uploadQrImage": "Muat Naik Imej QR", "uploadQrImage": "Muat Naik Imej QR",
@@ -58,7 +69,7 @@
"tryAgain": "Cuba Lagi", "tryAgain": "Cuba Lagi",
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...", "qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.", "geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.", "unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})",
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.", "qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
"updatePassword": "Kemaskini Kata Laluan", "updatePassword": "Kemaskini Kata Laluan",
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.", "passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
@@ -118,8 +129,12 @@
"tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.", "tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.",
"generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.", "generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.",
"reportGenerationError": "Ralat semasa menjana laporan.", "reportGenerationError": "Ralat semasa menjana laporan.",
"exportAll": "Eksport Semua",
"export": "Eksport",
"addNewUser": "Tambah Pengguna Baharu", "addNewUser": "Tambah Pengguna Baharu",
"fullName": "Nama Penuh", "fullName": "Nama Penuh",
"department": "Jabatan",
"position": "Jawatan",
"egJohnSmith": "cth. John Smith", "egJohnSmith": "cth. John Smith",
"egJsmith": "cth. jsmith", "egJsmith": "cth. jsmith",
"eg123456": "cth. 123456", "eg123456": "cth. 123456",
@@ -129,16 +144,20 @@
"manageTags": "Urus Tag", "manageTags": "Urus Tag",
"createNewTag": "Cipta Tag Baharu", "createNewTag": "Cipta Tag Baharu",
"egTeam": "cth. Pasukan", "egTeam": "cth. Pasukan",
"egSales": "cth. Jualan",
"egManager": "cth. Pengurus",
"createTag": "Cipta Tag", "createTag": "Cipta Tag",
"tags": "Tag", "tags": "Tag",
"workerRoster": "Senarai Pekerja", "workerRoster": "Senarai Pekerja",
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna", "searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
"searchByNameOrDepartment": "Cari mengikut nama atau jabatan",
"filterByTag": "Tapis mengikut tag", "filterByTag": "Tapis mengikut tag",
"clearFilter": "Padam tapisan", "clearFilter": "Padam tapisan",
"dateJoined": "Tarikh Sertai", "dateJoined": "Tarikh Sertai",
"actions": "Tindakan", "actions": "Tindakan",
"editTags": "Sunting Tag", "editTags": "Sunting Tag",
"viewRecords": "Lihat Rekod", "viewRecords": "Lihat Rekod",
"clearDevice": "Padam Peranti",
"delete": "Padam", "delete": "Padam",
"loadingWorkers": "Memuatkan pekerja...", "loadingWorkers": "Memuatkan pekerja...",
"noWorkersFound": "Tiada pekerja dijumpai.", "noWorkersFound": "Tiada pekerja dijumpai.",
@@ -179,5 +198,40 @@
"download": "Muat Turun", "download": "Muat Turun",
"noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!", "noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!",
"deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.", "deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.",
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun." "qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun.",
} "loading": "Memuatkan...",
"tabGeofencing": "Geofencing",
"createGeofence": "Cipta Geofence",
"drawInstruction": "Klik alat poligon di peta untuk mula menggambar geofence baru. Klik titik pertama untuk selesai.",
"geofenceName": "Nama Geofence",
"geofenceNamePlaceholder": "cth., Zon Gudang Utama",
"saveGeofence": "Simpan Geofence",
"existingGeofences": "Geofences Sedia Ada",
"view": "Lihat",
"noGeofencesFound": "Tiada Geofences Dijumpai",
"startOver" : "Mula Semula",
"workScheduleTitle": "Jadual Kerja",
"workScheduleDescription": "Klik pada tarikh untuk menukar statusnya. Hari yang didayakan berwarna hijau. Perubahan tidak akan disimpan sehingga anda mengklik 'Guna Perubahan'.",
"pendingChanges": "Perubahan Belum Selesai",
"noPendingChanges": "Klik pada kalendar untuk mendayakan atau menyahdayakan tarikh.",
"datesToEnable": "Dayakan tarikh ini:",
"datesToDisable": "Nyahdayakan tarikh ini:",
"applyChanges": "Guna Perubahan",
"discardChanges": "Buang Perubahan",
"statusClockedIn": "Anda Sudah Masuk Kerja",
"statusClockedOut": "Anda Sudah Keluar Kerja",
"scanToClockIn": "Imbas Kod QR untuk Masuk Kerja",
"scanToClockOut": "Imbas Kod QR untuk Keluar Kerja",
"error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.",
"error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.",
"error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.",
"error.outsideGeofence": "Gagal masuk/keluar: Anda berada di luar kawasan kerja yang ditetapkan sejauh {distance}m.",
"error.invalidQrCode": "Gagal masuk/keluar: Kod QR yang diimbas tidak sah atau tidak lagi aktif.",
"error.alreadyClockedIn": "Tindakan gagal: Anda sudah masuk kerja.",
"error.alreadyClockedOut": "Tindakan gagal: Anda sudah keluar kerja.",
"error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan."
}
+31 -28
View File
@@ -1,33 +1,33 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import LoginView from '../views/LoginView.vue' import LoginView from '../views/LoginView.vue'
import WorkerDashboardView from '../views/WorkerDashboardView.vue' // import WorkerDashboardView from '../views/WorkerDashboardView.vue'
import ManagerDashboardView from '../views/ManagerDashboardView.vue' import ManagerDashboardView from '../views/ManagerDashboardView.vue'
import WorkerHistoryView from '../views/WorkerHistoryView.vue' // import WorkerHistoryView from '../views/WorkerHistoryView.vue'
import AttendanceRecordView from '../views/AttendanceRecordView.vue' import AttendanceRecordView from '../views/AttendanceRecordView.vue'
import ChangePasswordView from '../views/ChangePasswordView.vue' // import ChangePasswordView from '../views/ChangePasswordView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ path: '/', name: 'login', component: LoginView }, { path: '/', name: 'login', component: LoginView },
{ // {
path: '/worker/dashboard', // path: '/worker/dashboard',
name: 'worker-dashboard', // name: 'worker-dashboard',
component: WorkerDashboardView, // component: WorkerDashboardView,
meta: { requiresAuth: true, role: 'worker' }, // meta: { requiresAuth: true, role: 'worker' },
}, // },
{ // {
path: '/worker/history', // path: '/worker/history',
name: 'worker-history', // name: 'worker-history',
component: WorkerHistoryView, // component: WorkerHistoryView,
meta: { requiresAuth: true, role: 'worker' }, // meta: { requiresAuth: true, role: 'worker' },
}, // },
{ // {
path: '/worker/change-password', // path: '/worker/change-password',
name: 'worker-change-password', // name: 'worker-change-password',
component: ChangePasswordView, // component: ChangePasswordView,
meta: { requiresAuth: true, role: 'worker' }, // meta: { requiresAuth: true, role: 'worker' },
}, // },
{ {
path: '/manager/dashboard', path: '/manager/dashboard',
name: 'manager-dashboard', name: 'manager-dashboard',
@@ -51,20 +51,23 @@ router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
if (isLoggedIn) { if (isLoggedIn) {
// Check if user has the required role // Since worker login is disabled, we only check for manager role
if (to.meta.role && to.meta.role === userRole) { if (userRole === 'manager') {
next() // User is logged in and has the correct role next()
} else { } else {
// User is logged in but trying to access a page for another role // If a non-manager is somehow logged in, or role is missing, redirect to login
// Redirect them to their own dashboard sessionStorage.clear() // Clear session for safety
next(userRole === 'worker' ? '/worker/dashboard' : '/manager/dashboard') next('/')
} }
} else { } else {
// User is not logged in, redirect to login page // User is not logged in, redirect to login page
next('/') next('/')
} }
} else if (to.name === 'login' && isLoggedIn && userRole === 'manager') {
// If a logged-in manager tries to visit the login page, redirect to their dashboard
next('/manager/dashboard')
} else { } else {
// For public routes like the login page // For public routes
next() next()
} }
}) })
+39 -6
View File
@@ -54,6 +54,10 @@
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0"> class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
{{ $t('filterRecords') }} {{ $t('filterRecords') }}
</button> </button>
<button @click="exportRawRecords" :disabled="exportLoading"
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50">
{{ exportLoading ? $t('exporting') : $t('export') }}
</button>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -150,6 +154,8 @@ const filters = ref({
endDate: today.toISOString().split('T')[0], endDate: today.toISOString().split('T')[0],
}) })
const exportLoading = ref(false);
const fetchRecords = async () => { const fetchRecords = async () => {
let url = `/api/managers/attendance-records?workerIds=${workerId}` let url = `/api/managers/attendance-records?workerIds=${workerId}`
if (filters.value.startDate && filters.value.endDate) { if (filters.value.startDate && filters.value.endDate) {
@@ -167,9 +173,9 @@ const fetchRecords = async () => {
} else { } else {
records.value = [] records.value = []
} }
} catch (err) { } catch (_err) {
console.error('Failed to fetch attendance records:', err) console.error('Failed to fetch attendance records:',_err)
alert(err.message) alert(_err.message)
records.value = [] records.value = []
} }
} }
@@ -202,12 +208,39 @@ const addManualClockOut = async () => {
manualClockOut.value.notes = '' manualClockOut.value.notes = ''
manualClockOut.value.timestamp = toLocalISOString(new Date()) manualClockOut.value.timestamp = toLocalISOString(new Date())
fetchRecords() fetchRecords()
} catch (err) { } catch (_err) {
console.error('Failed to submit manual clock-out:', err) console.error('Failed to submit manual clock-out:',_err)
alert(t('manualClockOutError', { msg: err.message })) alert(t('manualClockOutError', { msg: _err.message }))
} }
} }
const exportRawRecords = async () => {
exportLoading.value = true;
const { startDate, endDate } = filters.value;
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, {
headers: {
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
}
});
if (!response.ok) throw new Error('Network response was not ok.');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_err) {
alert('Failed to export records.');
} finally {
exportLoading.value = false;
}
};
onMounted(() => { onMounted(() => {
fetchRecords() fetchRecords()
}) })
+2 -4
View File
@@ -47,10 +47,8 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiFetch } from '@/api.js' import { apiFetch } from '@/api.js'
const { t } = useI18n()
const passwords = ref({ const passwords = ref({
currentPassword: '', currentPassword: '',
@@ -85,8 +83,8 @@ const handleChangePassword = async () => {
}) })
successMessage.value = 'passwordUpdated' successMessage.value = 'passwordUpdated'
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' } passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
} catch (err) { } catch (_err) {
errorMessage.value = err.message || 'passwordUpdateError' errorMessage.value = _err.message || 'passwordUpdateError'
} finally { } finally {
loading.value = false loading.value = false
} }
+29 -9
View File
@@ -11,14 +11,14 @@
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"> class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabPersonnel') }} {{ $t('tabPersonnel') }}
</button> </button>
<button @click="activeTab = 'attendance'" :class="{ <button @click="activeTab = 'warning'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold': 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'attendance', activeTab === 'warning',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600': 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'attendance', activeTab !== 'warning',
}" }"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"> class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabAttendance') }} {{ $t('tabWarning') }}
</button> </button>
<button @click="activeTab = 'qr'" :class="{ <button @click="activeTab = 'qr'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold': 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
@@ -29,23 +29,43 @@
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap"> class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabQrCodes') }} {{ $t('tabQrCodes') }}
</button> </button>
</div> <button @click="activeTab = 'geofencing'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'geofencing',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'geofencing',
}"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('tabGeofencing') }}
</button>
<button @click="activeTab = 'killSwitch'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'killSwitch',
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
activeTab !== 'killSwitch',
}"
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
{{ $t('workScheduleTitle') }}
</button>
</div>
<div class="tab-content"> <div class="tab-content">
<AttendanceReporting v-if="activeTab === 'attendance'" /> <WarningReporting v-if="activeTab === 'warning'" />
<QrCodeManagement v-if="activeTab === 'qr'" /> <QrCodeManagement v-if="activeTab === 'qr'" />
<PersonnelManagement v-if="activeTab === 'personnel'" /> <PersonnelManagement v-if="activeTab === 'personnel'" />
<GeofenceManagement v-if="activeTab === 'geofencing'" />
<KillSwitchManagement v-if="activeTab === 'killSwitch'" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import WarningReporting from '@/components/WarningReporting.vue'
import AttendanceReporting from '@/components/AttendanceReporting.vue'
import QrCodeManagement from '@/components/QrCodeManagement.vue' import QrCodeManagement from '@/components/QrCodeManagement.vue'
import PersonnelManagement from '@/components/PersonnelManagement.vue' import PersonnelManagement from '@/components/PersonnelManagement.vue'
import GeofenceManagement from '@/components/GeofenceManagement.vue'
import KillSwitchManagement from '@/components/KillSwitchManagement.vue'
const { t } = useI18n()
const activeTab = ref('personnel') const activeTab = ref('personnel')
</script> </script>