diff --git a/backend/hash_passwords.js b/backend/hash_passwords.js deleted file mode 100644 index 7a7b14a..0000000 --- a/backend/hash_passwords.js +++ /dev/null @@ -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() diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js new file mode 100644 index 0000000..bd331be --- /dev/null +++ b/backend/managerRoutes.js @@ -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; +} diff --git a/backend/server.js b/backend/server.js index 8aeb7e7..46c9054 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,25 +1,21 @@ -import express from 'express' -import cors from 'cors' -import { Parser } from 'json2csv' -import { v4 as uuidv4 } from 'uuid' -import mysql from 'mysql2/promise' -import dotenv from 'dotenv' -import bcrypt from 'bcrypt' -import jwt from 'jsonwebtoken' -// --- FIX START --- -// Import only the required functions from turf -import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf' -// --- FIX END --- +// server.js +import express from 'express'; +import cors from 'cors'; +import https from 'https'; +import http from 'http'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import dotenv from 'dotenv'; +import mysql from 'mysql2/promise'; +import managerRoutes from './managerRoutes.js'; +import workerRoutes from './workerRoutes.js'; - -// Main function to start the server async function startServer() { - dotenv.config() + dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }); - const app = express() - const port = 3000 + const app = express(); - // --- Database Connection --- const db = mysql.createPool({ host: process.env.DB_HOST, user: process.env.DB_USERNAME, @@ -29,676 +25,70 @@ async function startServer() { waitForConnections: true, connectionLimit: 10, queueLimit: 0, - }) + }); try { - const connection = await db.getConnection() - console.log('Database connected successfully!') - connection.release() + const connection = await db.getConnection(); + console.log('Database connected successfully!'); + connection.release(); } catch (error) { - console.error('!!! DATABASE CONNECTION FAILED !!!') - console.error('Error:', error.message) - process.exit(1) + console.error('!!! DATABASE CONNECTION FAILED !!!'); + console.error('Error:', error.message); + process.exit(1); } - // Define the geofence polygon by calling the 'polygon' function directly - const geofence = polygon([ - [ - [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 allowedOriginsFromEnv = (process.env.CORS_ALLOWED_ORIGINS || '').split(',').filter(Boolean); + const defaultAllowedOrigins = ['http://localhost:5173', 'https://localhost:5173', 'capacitor://localhost', 'ionic://localhost', 'http://localhost', 'https://localhost']; + const allowedOrigins = [...new Set([...defaultAllowedOrigins, ...allowedOriginsFromEnv])]; - - app.use(cors()) - app.use(express.json()) - - // --- API Endpoints --- - - // 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' }) - } + const corsOptions = { + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin || allowedOrigins.includes(origin) || origin.startsWith('capacitor://') || origin.startsWith('ionic://')) { + callback(null, true); } 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) { - console.error('Login error:', error) - 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) + console.error('❌ Failed to start HTTPS server:', error.message); } } - // Worker Clock In/Out Endpoint - app.post('/api/clock', authenticateJWT, async (req, res) => { - 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}`) - }) + http.createServer(app).listen(httpPort, '0.0.0.0', () => { + console.log(`🌐 HTTP Server is running on http://localhost:${httpPort}`); + }); } -startServer() +startServer(); diff --git a/backend/workerRoutes.js b/backend/workerRoutes.js new file mode 100644 index 0000000..f64320f --- /dev/null +++ b/backend/workerRoutes.js @@ -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; +} diff --git a/dev.sql b/dev.sql new file mode 100644 index 0000000..05787a8 --- /dev/null +++ b/dev.sql @@ -0,0 +1,207 @@ +# Host: localhost (Version: 5.7.26) +# Date: 2025-07-16 13:39:50 +# Generator: MySQL-Front 5.3 (Build 4.234) + +/*!40101 SET NAMES utf8 */; + +# +# Structure for table "app_blacklist" +# + +CREATE TABLE `app_blacklist` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `package_name` varchar(255) NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `package_name` (`package_name`) +) ENGINE=MyISAM AUTO_INCREMENT=50 DEFAULT CHARSET=utf8; + +# +# Data for table "app_blacklist" +# + +INSERT INTO `app_blacklist` VALUES (4,'com.lexa.fakegps','GPS Spoofing App','2025-07-09 11:59:55'),(5,'com.incorporateapps.fakegps.fre','GPS Spoofing App','2025-07-09 11:59:55'),(6,'com.blogspot.newapphorizons.fakegps','GPS Spoofing App','2025-07-09 11:59:55'),(7,'com.theappninjas.gpsjoystick','GPS Spoofing App','2025-07-09 11:59:55'),(8,'com.fakegps.mock','GPS Spoofing App','2025-07-09 11:59:55'),(9,'com.mock.location.app','GPS Spoofing App','2025-07-09 11:59:55'),(10,'com.fakegps.location','GPS Spoofing App','2025-07-09 11:59:55'),(11,'com.gpsemulator','GPS Spoofing App','2025-07-09 11:59:55'),(12,'com.locationspoofer','GPS Spoofing App','2025-07-09 11:59:55'),(13,'com.fakegps.pro','GPS Spoofing App','2025-07-09 11:59:55'),(14,'com.mock.gps.location','GPS Spoofing App','2025-07-09 11:59:55'),(15,'com.gps.mock.location','GPS Spoofing App','2025-07-09 11:59:55'),(16,'com.fake.location.spoofer','GPS Spoofing App','2025-07-09 11:59:55'),(17,'com.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(18,'com.gps.faker','GPS Spoofing App','2025-07-09 11:59:55'),(19,'com.mock.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(20,'com.location.mock.gps','GPS Spoofing App','2025-07-09 11:59:55'),(21,'com.gps.location.faker','GPS Spoofing App','2025-07-09 11:59:55'),(22,'com.fake.gps.location.spoofer','GPS Spoofing App','2025-07-09 11:59:55'),(23,'com.location.spoofer.gps','GPS Spoofing App','2025-07-09 11:59:55'),(24,'com.hola.mocklocation','Location Simulation App','2025-07-09 12:00:50'),(25,'com.lexa.fakegps.route','Location Simulation App','2025-07-09 12:00:50'),(26,'com.fakegps.mock.location.app','Location Simulation App','2025-07-09 12:00:50'),(27,'com.mock.location.app.free','Location Simulation App','2025-07-09 12:00:50'),(28,'com.location.mock.free','Location Simulation App','2025-07-09 12:00:50'),(29,'com.gps.mock.free','Location Simulation App','2025-07-09 12:00:50'),(33,'com.topjohnwu.magisk','Root Management/Evasion Tool','2025-07-09 12:01:02'),(34,'com.noshufou.android.su','Root Management/Evasion Tool','2025-07-09 12:01:02'),(35,'com.koushikdutta.superuser','Root Management/Evasion Tool','2025-07-09 12:01:02'),(36,'com.zachspong.temprootremovejb','Root Management/Evasion Tool','2025-07-09 12:01:02'),(37,'com.ramdroid.appquarantine','Root Management/Evasion Tool','2025-07-09 12:01:02'),(38,'com.devadvance.rootcloak','Root Management/Evasion Tool','2025-07-09 12:01:02'),(39,'com.devadvance.rootcloakplus','Root Management/Evasion Tool','2025-07-09 12:01:02'),(40,'de.robv.android.xposed.installer','Root Management/Evasion Tool','2025-07-09 12:01:02'),(41,'com.saurik.substrate','Root Management/Evasion Tool','2025-07-09 12:01:02'),(42,'com.amphoras.hidemyroot','Root Management/Evasion Tool','2025-07-09 12:01:02'),(43,'com.amphoras.hidemyrootadfree','Root Management/Evasion Tool','2025-07-09 12:01:02'),(44,'com.formyhm.hiderootPremium','Root Management/Evasion Tool','2025-07-09 12:01:02'),(45,'me.phh.superuser','Root Management/Evasion Tool','2025-07-09 12:01:02'),(46,'eu.chainfire.supersu','Root Management/Evasion Tool','2025-07-09 12:01:02'),(47,'com.kingouser.com','Root Management/Evasion Tool','2025-07-09 12:01:02'),(48,'com.android.vending.billing.InAppBillingService.LOCK','App Cracking/Patching Tool','2025-07-09 12:01:02'),(49,'com.android.vending.billing.InAppBillingService.LACK','App Cracking/Patching Tool','2025-07-09 12:01:02'); + +# +# Structure for table "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, + `notes` text, + PRIMARY KEY (`id`), + KEY `worker_id` (`worker_id`), + KEY `qr_code_id` (`qr_code_id`) +) ENGINE=MyISAM AUTO_INCREMENT=106 DEFAULT CHARSET=utf8 COMMENT='Logs every clock-in and clock-out event for all workers.'; + +# +# Data for table "clock_records" +# + +INSERT INTO `clock_records` VALUES (50,6,'clock_in','2025-07-08 14:09:24','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13490670,113.32259360,NULL),(51,6,'clock_out','2025-07-08 15:45:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13486030,113.32251780,NULL),(52,6,'clock_in','2025-07-08 15:46:05','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13486030,113.32251780,NULL),(53,4,'failed','2025-07-09 09:50:28','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134377.47 meters.'),(54,4,'failed','2025-07-09 09:52:56','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134377.47 meters.'),(55,4,'failed','2025-07-09 09:54:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,'Clock-in outside of the zone: 392.58 meters.'),(56,4,'failed','2025-07-09 09:57:11','9f72afba-ebb6-445d-a7fc-58df9902777b',23.12999830,113.31499830,'Clock-in outside of the zone: 376.27 meters.'),(57,4,'failed','2025-07-09 09:57:37','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13086670,113.32691330,'Clock-in outside of the zone: 455.18 meters.'),(58,4,'failed','2025-07-09 09:58:01','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13244650,113.32775730,'Clock-in outside of the zone: 399.79 meters.'),(59,4,'failed','2025-07-09 09:58:23','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13244670,113.32784160,'Clock-in outside of the zone: 406.12 meters.'),(60,4,'failed','2025-07-09 09:58:51','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312330,113.32783670,'Clock-in outside of the zone: 354.82 meters.'),(61,4,'failed','2025-07-09 10:05:28','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,'Clock-in outside of the zone: 354.57 meters.'),(62,4,'failed','2025-07-09 10:13:54','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,'Clock-in outside of the zone: 354.57 meters.'),(63,4,'clock_in','2025-07-09 10:16:51','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(64,4,'clock_out','2025-07-09 15:01:53','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(65,4,'clock_in','2025-07-09 15:06:55','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13312670,113.32783670,NULL),(66,4,'clock_out','2025-07-09 18:02:51','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Blacklisted App Detected'),(67,4,'failed','2025-07-09 18:08:08','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Forced clock-out failed: User already clocked out.'),(68,4,'clock_out','2025-07-09 18:16:54','FORCE_CLOCK_OUT',0.00000000,0.00000000,'Blacklisted App Detected'),(69,4,'failed','2025-07-09 18:22:09','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(70,4,'failed','2025-07-09 18:23:58','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42198810,-122.08399140,'Clock-in outside of the zone: 11134378.78 meters.'),(71,4,'failed','2025-07-09 18:24:58','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42198810,-122.08399140,'Clock-in outside of the zone: 11134378.78 meters.'),(72,4,'failed','2025-07-09 18:29:30','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,'Clock-in outside of the zone: 392.58 meters.'),(73,4,'failed','2025-07-10 10:13:12','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134237.56 meters.'),(74,4,'failed','2025-07-10 10:13:43','9f72afba-ebb6-445d-a7fc-58df9902777b',37.42199830,-122.08400000,'Clock-in outside of the zone: 11134237.56 meters.'),(75,4,'failed','2025-07-10 10:14:45','9f72afba-ebb6-445d-a7fc-58df9902777b',25.21599040,141.62428650,'Clock-in outside of the zone: 2871482.21 meters.'),(76,4,'failed','2025-07-10 10:14:48','9f72afba-ebb6-445d-a7fc-58df9902777b',23.92816100,124.13230650,'Clock-in outside of the zone: 1101840.15 meters.'),(77,4,'clock_in','2025-07-10 10:18:00','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(78,4,'clock_out','2025-07-10 10:38:50','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(79,4,'failed','2025-07-10 10:59:12','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(80,4,'clock_out','2025-07-10 11:05:34','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(81,6,'clock_out','2025-07-10 11:11:04','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13362700,113.32298620,NULL),(82,6,'clock_in','2025-07-10 11:11:44','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13362700,113.32298620,NULL),(83,6,'clock_out','2025-07-10 11:25:20','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(84,6,'clock_in','2025-07-10 11:29:30','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(85,6,'clock_out','2025-07-10 11:29:34','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(86,6,'clock_in','2025-07-10 11:29:53','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(87,6,'clock_out','2025-07-10 11:29:58','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(88,6,'clock_in','2025-07-10 11:48:36','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(89,6,'clock_out','2025-07-10 11:48:40','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13370560,113.32305440,NULL),(90,4,'failed','2025-07-10 13:29:58','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(91,10,'clock_out','2025-07-10 13:31:07','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(92,10,'failed','2025-07-10 13:32:06','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(93,10,'clock_out','2025-07-10 13:33:38','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(94,10,'failed','2025-07-10 13:40:42','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(95,10,'clock_out','2025-07-10 13:41:01','FORCE_CLOCK_OUT',NULL,NULL,'Blacklisted App Detected'),(96,10,'failed','2025-07-10 13:47:44','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(97,10,'failed','2025-07-10 13:48:00','FORCE_CLOCK_OUT',0.00000000,0.00000000,'FAKE GPS APP Detected.'),(98,10,'clock_in','2025-07-10 14:04:48','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(99,4,'clock_in','2025-07-10 14:17:24','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(100,4,'clock_out','2025-07-10 14:18:00','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(101,4,'clock_in','2025-07-15 08:41:40','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(102,4,'clock_out','2025-07-15 08:48:43','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(103,4,'clock_in','2025-07-15 08:52:03','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(104,4,'clock_out','2025-07-15 08:52:09','9f72afba-ebb6-445d-a7fc-58df9902777b',23.13269830,113.32791330,NULL),(105,10,'clock_out','2025-07-16 09:44:00',NULL,NULL,NULL,'test'); + +# +# Structure for table "geofences" +# + +CREATE TABLE `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 NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=MyISAM AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; + +# +# Data for table "geofences" +# + +INSERT INTO `geofences` VALUES (1,'Main Work Area','[[113.35311466293217,23.161344441258407],[113.28591534444001,23.161344441258407],[113.28591534444001,23.091366234233973],[113.35311466293217,23.091366234233973],[113.35311466293217,23.161344441258407]]',1,'2025-07-14 16:07:32'); + +# +# Structure for table "location_updates" +# + +CREATE TABLE `location_updates` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `longitude` decimal(11,8) NOT NULL COMMENT 'Longitude first for geographic convention', + `latitude` decimal(10,8) NOT NULL COMMENT 'Latitude second for geographic convention', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Single timestamp field', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_created_at` (`created_at`), + KEY `idx_user_created` (`user_id`,`created_at`) COMMENT 'Composite index for user location history' +) ENGINE=MyISAM AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='Optimized for 30-minute updates - essential fields only (longitude, latitude, created_at)'; + +# +# Data for table "location_updates" +# + +INSERT INTO `location_updates` VALUES (1,4,113.32791330,23.13269830,'2025-07-15 16:41:41'),(2,4,113.32791330,23.13269830,'2025-07-15 16:52:04'); + +# +# Structure for table "qr_codes" +# + +CREATE TABLE `qr_codes` ( + `id` varchar(255) NOT NULL COMMENT 'Using the UUID string as the primary key', + `name` varchar(255) NOT NULL, + `is_active` tinyint(1) NOT NULL DEFAULT '1', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='Stores all physical QR code locations and their status.'; + +# +# Data for table "qr_codes" +# + +INSERT INTO `qr_codes` VALUES ('19e8f029-2e61-4b34-af3a-ee985f2cff74','Gate A -2',1,'2025-06-20 14:42:29'),('4afb2111-cff8-4706-bc87-44518492d5f6','test',1,'2025-07-02 11:41:21'),('9f72afba-ebb6-445d-a7fc-58df9902777b','GATE A',1,'2025-06-26 15:56:14'),('ASSEMBLY-LINE-1','Assembly Line 1',1,'2025-06-13 13:39:51'),('d654a6bf-2b48-49e9-95c8-4fe9af6c3e44','Gate B',1,'2025-06-13 14:00:31'),('d7ac9594-ad9f-48dc-b984-5a9e7ea7e995','weast',1,'2025-06-20 14:55:09'),('FACTORY-MAIN-ENTRANCE','Factory Main Entrance',1,'2025-06-13 13:39:51'),('WAREHOUSE-SECTION-A','Warehouse Section A',1,'2025-06-13 13:39:51'); + +# +# Structure for table "security_alerts" +# + +CREATE TABLE `security_alerts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `alert_type` varchar(100) NOT NULL, + `alert_data` json DEFAULT NULL, + `severity` enum('low','medium','high','critical') DEFAULT 'medium', + `is_resolved` tinyint(1) DEFAULT '0', + `resolved_at` timestamp NULL DEFAULT NULL, + `resolved_by` int(11) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `resolved_by` (`resolved_by`), + KEY `idx_user_id` (`user_id`), + KEY `idx_alert_type` (`alert_type`), + KEY `idx_severity` (`severity`), + KEY `idx_is_resolved` (`is_resolved`), + KEY `idx_created_at` (`created_at`) +) ENGINE=MyISAM AUTO_INCREMENT=272 DEFAULT CHARSET=utf8; + +# +# Data for table "security_alerts" +# + + +# +# Structure for table "security_checks" +# + +CREATE TABLE `security_checks` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `device_info` json DEFAULT NULL, + `security_data` json DEFAULT NULL, + `risk_level` enum('low','medium','high') DEFAULT 'low', + `risk_score` int(11) DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_risk_level` (`risk_level`), + KEY `idx_timestamp` (`timestamp`), + KEY `idx_created_at` (`created_at`) +) ENGINE=MyISAM AUTO_INCREMENT=107 DEFAULT CHARSET=utf8; + +# +# Data for table "security_checks" +# + + +# +# Structure for table "workers" +# + +CREATE TABLE `workers` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `username` varchar(255) NOT NULL, + `password_hash` varchar(255) NOT NULL COMMENT 'Store hashed passwords, not plain text!', + `full_name` varchar(255) NOT NULL, + `role` enum('worker','manager') NOT NULL, + `device_uuid` varchar(255) DEFAULT NULL, + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `department` varchar(50) DEFAULT NULL, + `position` varchar(100) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`), + KEY `idx_device_uuid` (`device_uuid`) +) ENGINE=MyISAM AUTO_INCREMENT=29 DEFAULT CHARSET=utf8 COMMENT='Stores user account information for both workers and managers.'; + +# +# Data for table "workers" +# + +INSERT INTO `workers` VALUES (1,'worker','$2b$10$ej9XEoBLd6Gl0OJvQbScdeACzdHt98VJVsxs7PqV6XSyhfsVCGfNe','John Doe','worker',NULL,'2025-06-13 13:39:51','test','test'),(2,'worker2','$2b$10$SxjhV19fhO1ILISNxxVJXeJ23Z3p/Dclt47c0j7SfuliROKDpANQC','Jane Smith','worker','7cf298e6-7cf2-4cf2-f306-5365d6d7','2025-06-13 13:39:51','test','test'),(3,'manager','$2b$10$GkPmwkSIxv0d6z/R6S/PCe5NLwgKtAOP7/FDCUwR1vCFZ7ex3FeNi','Manager Bob','manager',NULL,'2025-06-13 13:39:51','test','test'),(4,'ryanlee','$2b$10$jsYy.2SzpJ9A0LWu6CpoK.mZ3GZWZoIp8g81sRfKt2G4Dipjp8Sa6','Ryan Lee','worker','557fd11d-557f-457f-f214-14df7cf1','2025-06-13 14:48:06','testb','test'),(6,'modewang','$2b$10$4gb.m2IgY9iJoVjyVUQ9IuUmiKNuR6TlxUsXmiaSpf8XFrnko8bcG','Ryan Qi','worker',NULL,'2025-06-13 15:53:08','testb','test'),(8,'bwilliams','$2b$10$MsJ1baJE.g4tAm1HOhTFD.cz1vd80BRPQE//hNVuikdLf2QOhdLi6','Bob Williams','worker',NULL,'2025-06-13 15:57:30','testb','test'),(10,'dmiller','$2b$10$e0cpp2JcfExeB9APu2Hbf.H21rn8XBcCsJOCuVUjltlBqTOOQJuDm','Diana Miller','worker','3a2641b6-3a26-4a26-ee92-0d1ea028','2025-06-13 15:57:30','testb','test'); + +# +# Structure for table "active_user_devices" +# + +CREATE VIEW `active_user_devices` AS + select `w`.`id` AS `id`,`w`.`id` AS `user_id`,`w`.`device_uuid` AS `device_uuid`,NULL AS `device_info`,NULL AS `registered_at`,NULL AS `last_seen`,(case when (`w`.`device_uuid` is not null) then 1 else 0 end) AS `is_online`,`w`.`created_at` AS `created_at`,`w`.`created_at` AS `updated_at`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,`w`.`role` AS `role`,NULL AS `minutes_since_last_seen` from `workers` `w` where ((`w`.`device_uuid` is not null) and (`w`.`role` = 'worker')); + +# +# Structure for table "recent_location_updates" +# + +CREATE VIEW `recent_location_updates` AS + select `lu`.`id` AS `id`,`lu`.`user_id` AS `user_id`,`lu`.`longitude` AS `longitude`,`lu`.`latitude` AS `latitude`,`lu`.`created_at` AS `created_at`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,timestampdiff(MINUTE,`lu`.`created_at`,now()) AS `minutes_ago` from (`location_updates` `lu` join `workers` `w` on((`lu`.`user_id` = `w`.`id`))) where (`lu`.`created_at` > (now() - interval 24 hour)) order by `lu`.`created_at` desc; + +# +# Structure for table "security_summary" +# + +CREATE VIEW `security_summary` AS + select `w`.`id` AS `user_id`,`w`.`username` AS `username`,`w`.`full_name` AS `full_name`,`sc`.`risk_level` AS `latest_risk_level`,`sc`.`risk_score` AS `latest_risk_score`,`sc`.`created_at` AS `last_security_check`,count(`sa`.`id`) AS `active_alerts`,`w`.`device_uuid` AS `current_device`,NULL AS `device_last_seen` from ((`workers` `w` left join `security_checks` `sc` on(((`w`.`id` = `sc`.`user_id`) and (`sc`.`id` = (select max(`security_checks`.`id`) from `security_checks` where (`security_checks`.`user_id` = `w`.`id`)))))) left join `security_alerts` `sa` on(((`w`.`id` = `sa`.`user_id`) and (`sa`.`is_resolved` = FALSE)))) where (`w`.`role` = 'worker') group by `w`.`id`,`w`.`username`,`w`.`full_name`,`sc`.`risk_level`,`sc`.`risk_score`,`sc`.`created_at`,`w`.`device_uuid`; diff --git a/eslint.config.js b/eslint.config.js index c1faee0..7dbe8ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,6 +8,13 @@ export default defineConfig([ { name: 'app/files-to-lint', 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/**']), diff --git a/geofence_simple.sql b/geofence_simple.sql new file mode 100644 index 0000000..238ffa8 --- /dev/null +++ b/geofence_simple.sql @@ -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]]'); diff --git a/nilai_clock.sql b/nilai_clock.sql deleted file mode 100644 index a29783e..0000000 --- a/nilai_clock.sql +++ /dev/null @@ -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 */; diff --git a/package-lock.json b/package-lock.json index 156df1a..3c42697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "bcrypt": "^6.0.0", "body-parser": "^2.2.0", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "html5-qrcode": "^2.3.8", "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.14.1", + "mysql2": "^3.14.2", "primevue": "^4.3.5", "qrcode": "^1.5.4", "uuid": "^11.1.0", @@ -31,6 +31,8 @@ "devDependencies": { "@eslint/js": "^9.22.0", "@tailwindcss/vite": "^4.1.10", + "@types/leaflet": "^1.9.20", + "@types/leaflet-draw": "^1.0.12", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.21", @@ -38,6 +40,8 @@ "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", "globals": "^16.0.0", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "postcss": "^8.5.6", "prettier": "3.5.3", "tailwindcss": "^4.1.10", @@ -4458,6 +4462,26 @@ "dev": true, "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": { "version": "24.0.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", @@ -5505,9 +5529,9 @@ "license": "MIT" }, "node_modules/dotenv": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", - "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -7039,6 +7063,20 @@ "dev": true, "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7550,9 +7588,9 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz", - "integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.2.tgz", + "integrity": "sha512-YD6mZMeoypmheHT6b2BrVmQFvouEpRICuvPIREulx2OvP1xAxxeqkMQqZSTBefv0PiOBKGYFa2zQtY+gf/4eQw==", "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", diff --git a/package.json b/package.json index 2f72148..2232213 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "bcrypt": "^6.0.0", "body-parser": "^2.2.0", "cors": "^2.8.5", - "dotenv": "^16.5.0", + "dotenv": "^16.6.1", "express": "^5.1.0", "html5-qrcode": "^2.3.8", "json2csv": "^6.0.0-alpha.2", "jsonwebtoken": "^9.0.2", - "mysql2": "^3.14.1", + "mysql2": "^3.14.2", "primevue": "^4.3.5", "qrcode": "^1.5.4", "uuid": "^11.1.0", @@ -36,6 +36,8 @@ "devDependencies": { "@eslint/js": "^9.22.0", "@tailwindcss/vite": "^4.1.10", + "@types/leaflet": "^1.9.20", + "@types/leaflet-draw": "^1.0.12", "@vitejs/plugin-vue": "^5.2.3", "@vue/eslint-config-prettier": "^10.2.0", "autoprefixer": "^10.4.21", @@ -43,6 +45,8 @@ "eslint": "^9.22.0", "eslint-plugin-vue": "~10.0.0", "globals": "^16.0.0", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", "postcss": "^8.5.6", "prettier": "3.5.3", "tailwindcss": "^4.1.10", diff --git a/src/api.js b/src/api.js index f6e6ec8..4d2aee6 100644 --- a/src/api.js +++ b/src/api.js @@ -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 = {}) { - const token = sessionStorage.getItem('token') + const token = sessionStorage.getItem('token'); const defaultHeaders = { 'ngrok-skip-browser-warning': 'true', 'Content-Type': 'application/json', ...options.headers, - } + }; 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}`, { ...options, headers: defaultHeaders, - }) + }); if (!response.ok) { - // Try to parse the error response body from the server - const errorData = await response.json() - throw new Error(errorData.message || `API call failed with status: ${response.status}`) - } - if (response.status === 204) { - return null + // Check content type of the error response + const contentType = response.headers.get('content-type'); + let errorData; + + // If the server sends back a JSON error, parse it. + 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(); } diff --git a/src/components/AttendanceReporting.vue b/src/components/AttendanceReporting.vue deleted file mode 100644 index 14931c0..0000000 --- a/src/components/AttendanceReporting.vue +++ /dev/null @@ -1,653 +0,0 @@ - - - - - diff --git a/src/components/GeofenceManagement.vue b/src/components/GeofenceManagement.vue new file mode 100644 index 0000000..0539858 --- /dev/null +++ b/src/components/GeofenceManagement.vue @@ -0,0 +1,230 @@ + + + diff --git a/src/components/KillSwitchManagement.vue b/src/components/KillSwitchManagement.vue new file mode 100644 index 0000000..ab45145 --- /dev/null +++ b/src/components/KillSwitchManagement.vue @@ -0,0 +1,193 @@ + + + diff --git a/src/components/PersonnelManagement.vue b/src/components/PersonnelManagement.vue index 64a5ee3..63f0a6a 100644 --- a/src/components/PersonnelManagement.vue +++ b/src/components/PersonnelManagement.vue @@ -2,92 +2,56 @@

{{ $t('addNewUser') }}

-
+
- - + +
- - + +
- - + + +
+
+ + +
+
+ +
-

{{ errorMessage }}

-
-

{{ $t('manageTags') }}

-
-
- - -
- -
-
- - {{ tag.tag_name }} - - -
-
-

{{ $t('workerRoster') }}

-
- -
- {{ $t('filterByTag') }} - - +
+ +
+
+ + +
+
+ + +
+
@@ -95,529 +59,259 @@ - - - - {{ $t('fullName') }} - - - {{ $t('username') }} - - - {{ $t('tags') }} - - - {{ $t('dateJoined') }} - - - {{ $t('actions') }} + + {{ $t('fullName') }} + {{ $t('username') }} + {{ $t('department') }} + {{ $t('position') }} + {{ $t('dateJoined') }} + {{ $t('actions') }} - + - + {{ worker.full_name }} {{ worker.username }} - - - {{ $t('nA') }} - - - {{ new Date(worker.created_at).toLocaleDateString() }} - + {{ worker.department }} + {{ worker.position }} + {{ new Date(worker.created_at).toLocaleDateString() }} - - - - + + + + - + {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
-
- - - {{ $t('pageOf', { current: currentPage, total: totalPages }) }} - - +
+ + {{ $t('pageOf', { current: currentPage, total: totalPages }) }} +
-
-
-

{{ editorTitle }}

-
- -

- {{ $t('noTagsAvailable') }} -

-
- -
-
- -
+

{{ $t('changePassword') }}

- For user: {{ $t('forUser') }}: ... + {{ $t('forUser') }}: {{ editingWorkerPassword.full_name }}

-
- - + +
- - + +
-

- {{ passwordErrorMessage }} -

-

- {{ passwordSuccessMessage }} -

+

{{ passwordErrorMessage }}

+

{{ passwordSuccessMessage }}

- - +
- -
- {{ selectedWorkerIds.length }} worker(s) selected - - -
- - diff --git a/src/components/QrCodeManagement.vue b/src/components/QrCodeManagement.vue index 3502ff8..c2942d4 100644 --- a/src/components/QrCodeManagement.vue +++ b/src/components/QrCodeManagement.vue @@ -117,8 +117,8 @@ const fetchQrCodes = async () => { // CORRECT: Get the data directly from apiFetch const data = await apiFetch('/api/managers/qr-codes') qrCodes.value = data - } catch (err) { - console.error('Failed to fetch QR codes:', err) + } catch (_err) { + console.error('Failed to fetch QR codes:',_err) } } @@ -144,8 +144,8 @@ const addQrCode = async () => { if (error) console.error(error) }, ) - } catch (err) { - console.error('Failed to add QR code:', err) + } catch (_err) { + console.error('Failed to add QR code:',_err) } } @@ -162,8 +162,8 @@ const toggleQrStatus = async (qr) => { if (index !== -1) { qrCodes.value[index].is_active = !qrCodes.value[index].is_active } - } catch (err) { - console.error('Failed to update QR status:', err) + } catch (_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 qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id) - } catch (err) { - console.error('Failed to delete QR code:', err) + } catch (_err) { + console.error('Failed to delete QR code:',_err) } } diff --git a/src/components/WarningReporting.vue b/src/components/WarningReporting.vue new file mode 100644 index 0000000..5496d4b --- /dev/null +++ b/src/components/WarningReporting.vue @@ -0,0 +1,187 @@ + + + diff --git a/src/locales/en.json b/src/locales/en.json index 695e426..a0aa793 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -10,6 +10,7 @@ "invalidToken": "Invalid token received from server.", "english": "English", "malay": "Malay", + "setting": "Setting", "yourStatus": "Your Status", "clockedIn": "Clocked In", @@ -24,19 +25,20 @@ "out": "Out", "cancel": "Cancel", + "clockHistory": "Clock History", "viewMyClockHistory": "View My Clock History", "changeMyPassword": "Change My Password", + "updateYourPassword": "Update Your Password", "myClockHistory": "My Clock History", "backToDashboard": "Back to Dashboard", "noClockHistory": "You have no clocking history.", "clockHistoryFetchFail": "Failed to fetch clock history:", - "viewClockHistory": "View My Clock History →", - "changePassword": "Change My Password →", + "viewClockHistory": "View My Clock History", + "changePassword": "Change My Password", "successClockIn": "Successfully clocked in.", "successClockOut": "Successfully clocked out.", "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}.", "changePasswordTitle": "Change Password", "currentPassword": "Current Password", @@ -46,6 +48,14 @@ "tabPersonnel": "Personnel", "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", "uploadQrImage": "Upload QR Image", @@ -58,7 +68,7 @@ "tryAgain": "Try Again", "qrDetectedGettingLocation": "QR Code detected. Getting location...", "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.", "updatePassword": "Update Password", "passwordsNoMatch": "New passwords do not match.", @@ -118,9 +128,13 @@ "tagLoadError": "Could not load workers for the selected tag.", "generateReportError": "Please select workers, set valid date range, and enter a salary.", "reportGenerationError": "An error occurred while generating the report.", + "exportAll": "Export All", + "export": "Export", "addNewUser": "Add New User", "fullName": "Full Name", + "department": "Department", + "position": "Position", "egJohnSmith": "e.g. John Smith", "egJsmith": "e.g. jsmith", "eg123456": "e.g. 123456", @@ -130,16 +144,20 @@ "manageTags": "Manage Tags", "createNewTag": "Create New Tag", "egTeam": "e.g. Team", + "egSales": "e.g. Sales", + "egManager": "e.g. Manager", "createTag": "Create Tag", "tags": "Tags", "workerRoster": "Worker Roster", "searchByNameOrUsername": "Search by name or username", + "searchByNameOrDepartment": "Search by name or department", "filterByTag": "Filter by tag", "clearFilter": "Clear filter", "dateJoined": "Date Joined", "actions": "Actions", "editTags": "Edit Tags", "viewRecords": "View Records", + "clearDevice": "Clear Device", "delete": "Delete", "loadingWorkers": "Loading workers...", "noWorkersFound": "No workers found.", @@ -180,5 +198,40 @@ "download": "Download", "noQrCodesFound": "No QR codes found. Create one above!", "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." +} \ No newline at end of file diff --git a/src/locales/ms.json b/src/locales/ms.json index 0148b11..8799ef7 100644 --- a/src/locales/ms.json +++ b/src/locales/ms.json @@ -1,5 +1,5 @@ { - "appTitle": "Sistem Masuk/Keluar Kerja", + "appTitle": "Sistem Kehadiran", "logout": "Log Keluar", "login": "Log Masuk", "username": "Nama Pengguna", @@ -10,7 +10,8 @@ "invalidToken": "Token tidak sah diterima dari pelayan.", "english": "Bahasa Inggeris", "malay": "Bahasa Melayu", - + "setting": "Tetapan", + "yourStatus": "Status Anda", "clockedIn": "Sudah Masuk", "clockedOut": "Sudah Keluar", @@ -24,14 +25,16 @@ "out": "Keluar", "cancel": "Batal", + "clockHistroy": "Sejarah Kehadiran", "viewMyClockHistory": "Lihat Sejarah Kehadiran Saya", "changeMyPassword": "Tukar Kata Laluan Saya", + "updateYourPassword": "Tukar Kata Laluan Anda", "myClockHistory": "Sejarah Kehadiran Saya", "backToDashboard": "Kembali ke Papan Pemuka", "noClockHistory": "Tiada rekod kehadiran.", "clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:", - "viewClockHistory": "Lihat Sejarah Kehadiran Saya →", - "changePassword": "Tukar Kata Laluan Saya →", + "viewClockHistory": "Lihat Sejarah Kehadiran Saya", + "changePassword": "Tukar Kata Laluan Saya", "successClockIn": "Berjaya masuk kerja.", "successClockOut": "Berjaya keluar kerja.", @@ -46,6 +49,14 @@ "tabPersonnel": "Personel", "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", "uploadQrImage": "Muat Naik Imej QR", @@ -58,7 +69,7 @@ "tryAgain": "Cuba Lagi", "qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...", "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.", "updatePassword": "Kemaskini Kata Laluan", "passwordsNoMatch": "Kata laluan baharu tidak sepadan.", @@ -118,8 +129,12 @@ "tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.", "generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.", "reportGenerationError": "Ralat semasa menjana laporan.", + "exportAll": "Eksport Semua", + "export": "Eksport", "addNewUser": "Tambah Pengguna Baharu", "fullName": "Nama Penuh", + "department": "Jabatan", + "position": "Jawatan", "egJohnSmith": "cth. John Smith", "egJsmith": "cth. jsmith", "eg123456": "cth. 123456", @@ -129,16 +144,20 @@ "manageTags": "Urus Tag", "createNewTag": "Cipta Tag Baharu", "egTeam": "cth. Pasukan", + "egSales": "cth. Jualan", + "egManager": "cth. Pengurus", "createTag": "Cipta Tag", "tags": "Tag", "workerRoster": "Senarai Pekerja", "searchByNameOrUsername": "Cari mengikut nama atau nama pengguna", + "searchByNameOrDepartment": "Cari mengikut nama atau jabatan", "filterByTag": "Tapis mengikut tag", "clearFilter": "Padam tapisan", "dateJoined": "Tarikh Sertai", "actions": "Tindakan", "editTags": "Sunting Tag", "viewRecords": "Lihat Rekod", + "clearDevice": "Padam Peranti", "delete": "Padam", "loadingWorkers": "Memuatkan pekerja...", "noWorkersFound": "Tiada pekerja dijumpai.", @@ -179,5 +198,40 @@ "download": "Muat Turun", "noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!", "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." +} \ No newline at end of file diff --git a/src/router/index.js b/src/router/index.js index 031173d..237f40c 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,33 +1,33 @@ import { createRouter, createWebHashHistory } from 'vue-router' 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 WorkerHistoryView from '../views/WorkerHistoryView.vue' +// import WorkerHistoryView from '../views/WorkerHistoryView.vue' import AttendanceRecordView from '../views/AttendanceRecordView.vue' -import ChangePasswordView from '../views/ChangePasswordView.vue' +// import ChangePasswordView from '../views/ChangePasswordView.vue' const router = createRouter({ history: createWebHashHistory(), routes: [ { path: '/', name: 'login', component: LoginView }, - { - path: '/worker/dashboard', - name: 'worker-dashboard', - component: WorkerDashboardView, - meta: { requiresAuth: true, role: 'worker' }, - }, - { - path: '/worker/history', - name: 'worker-history', - component: WorkerHistoryView, - meta: { requiresAuth: true, role: 'worker' }, - }, - { - path: '/worker/change-password', - name: 'worker-change-password', - component: ChangePasswordView, - meta: { requiresAuth: true, role: 'worker' }, - }, + // { + // path: '/worker/dashboard', + // name: 'worker-dashboard', + // component: WorkerDashboardView, + // meta: { requiresAuth: true, role: 'worker' }, + // }, + // { + // path: '/worker/history', + // name: 'worker-history', + // component: WorkerHistoryView, + // meta: { requiresAuth: true, role: 'worker' }, + // }, + // { + // path: '/worker/change-password', + // name: 'worker-change-password', + // component: ChangePasswordView, + // meta: { requiresAuth: true, role: 'worker' }, + // }, { path: '/manager/dashboard', name: 'manager-dashboard', @@ -51,20 +51,23 @@ router.beforeEach((to, from, next) => { if (to.meta.requiresAuth) { if (isLoggedIn) { - // Check if user has the required role - if (to.meta.role && to.meta.role === userRole) { - next() // User is logged in and has the correct role + // Since worker login is disabled, we only check for manager role + if (userRole === 'manager') { + next() } else { - // User is logged in but trying to access a page for another role - // Redirect them to their own dashboard - next(userRole === 'worker' ? '/worker/dashboard' : '/manager/dashboard') + // If a non-manager is somehow logged in, or role is missing, redirect to login + sessionStorage.clear() // Clear session for safety + next('/') } } else { // User is not logged in, redirect to login page 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 { - // For public routes like the login page + // For public routes next() } }) diff --git a/src/views/AttendanceRecordView.vue b/src/views/AttendanceRecordView.vue index 62fd609..9de4ca8 100644 --- a/src/views/AttendanceRecordView.vue +++ b/src/views/AttendanceRecordView.vue @@ -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"> {{ $t('filterRecords') }} +
@@ -150,6 +154,8 @@ const filters = ref({ endDate: today.toISOString().split('T')[0], }) +const exportLoading = ref(false); + const fetchRecords = async () => { let url = `/api/managers/attendance-records?workerIds=${workerId}` if (filters.value.startDate && filters.value.endDate) { @@ -167,9 +173,9 @@ const fetchRecords = async () => { } else { records.value = [] } - } catch (err) { - console.error('Failed to fetch attendance records:', err) - alert(err.message) + } catch (_err) { + console.error('Failed to fetch attendance records:',_err) + alert(_err.message) records.value = [] } } @@ -202,12 +208,39 @@ const addManualClockOut = async () => { manualClockOut.value.notes = '' manualClockOut.value.timestamp = toLocalISOString(new Date()) fetchRecords() - } catch (err) { - console.error('Failed to submit manual clock-out:', err) - alert(t('manualClockOutError', { msg: err.message })) + } catch (_err) { + console.error('Failed to submit manual clock-out:',_err) + 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(() => { fetchRecords() }) diff --git a/src/views/ChangePasswordView.vue b/src/views/ChangePasswordView.vue index 2afa20e..9065909 100644 --- a/src/views/ChangePasswordView.vue +++ b/src/views/ChangePasswordView.vue @@ -47,10 +47,8 @@