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 @@
-
-
- {{ $t('noWorkersSelected') }}
-
- {{ $t('salaryAppliedNote') }}
- {{ $t('weekendFactor') }} {{ $t('holidayFactor') }} {{ $t('loadingReport') }} {{ $t('drawInstruction') }}{{ $t('selectWorkers') }}
-
-
-
-
- {{ $t('selectedForReport', { count: selectedWorkers.length }) }}
-
-
-
-
-
-
-
-
- {{ $t('reportSettings') }}
-
- {{ $t('monthlySalary') }}
-
- {{ $t('otFactors') }}
-
- {{ $t('selectPublicHolidays') }}
-
-
- {{ $t('overtimePaySummary') }}
-
-
-
-
-
-
-
-
-
-
- {{ $t('worker') }}
-
-
- {{ $t('totalHoursWorked') }}
-
-
- {{ $t('totalOtPay') }}
-
-
-
-
- {{ name }}
-
- {{ report.totalHours.toFixed(2) }}
-
-
- {{ report.totalOtPay.toFixed(2) }}
-
-
- {{ $t('rawAttendanceData') }}
-
-
- {{ workerName }}
-
-
-
-
-
-
-
-
-
- {{ $t('event') }}
-
-
- {{ $t('timsstamp') }}
-
-
- {{ $t('location') }}
-
-
- {{ $t('notes') }}
-
-
-
-
-
-
- {{ record.event_type.replace('_', ' ') }}
-
-
-
- {{ new Date(record.timestamp).toLocaleString() }}
-
-
- {{ record.qrCodeUsedName }}
-
- {{ record.notes || $t('nA') }}
- {{ $t('createGeofence') }}
+ {{ $t('existingGeofences') }}
+
+
+
+
+
+
+
+
+ {{ $t('name') }}
+
+
+ {{ $t('status') }}
+
+
+ {{ $t('actions') }}
+
+
+
+ {{ fence.name }}
+
+
+ {{ $t(fence.is_active ? 'active' : 'inactive') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('noGeofencesFound') }}
+
+
+ {{ monthYear }}
+
+ {{ $t('pendingChanges') }}
+ {{ $t('datesToEnable') }}
+
+
+ {{ $t('datesToDisable') }}
+
+
+
{{ errorMessage }}
-- {{ $t('noTagsAvailable') }} -
-- For user: {{ $t('forUser') }}: ... + {{ $t('forUser') }}: {{ editingWorkerPassword.full_name }}
-| + {{ $t('worker') }} + {{ sortDirection === 'asc' ? '↑' : '↓' }} + | ++ {{ $t('failedCount') }} + {{ sortDirection === 'asc' ? '↑' : '↓' }} + | +{{ $t('actions') }} | +
|---|---|---|
| {{ record.full_name }} | +{{ record.count }} | +
+ |
+
| + {{ $t('noRecordsFound') }} + | +||
|
+
+
+ {{ $t('loadingReport') }}
+
+ |
+ ||
| {{ $t('timestamp') }} | +{{ $t('eventType') }} | +{{ $t('location') }} | +{{ $t('notes') }} | +
|---|---|---|---|
| {{ new Date(detail.timestamp).toLocaleString() }} | ++ + {{ $t(detail.event_type) }} + + | +{{ detail.qrCodeUsedName || $t('nA') }} | +{{ detail.notes || $t('nA') }} | +