Merge branch 'main' into edison_dev2
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1,635 @@
|
||||
import express from 'express';
|
||||
import { Parser } from 'json2csv';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export default function(db) {
|
||||
const router = express.Router();
|
||||
|
||||
// Middleware to authenticate and authorize managers
|
||||
const authenticateJWT = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader) {
|
||||
const token = authHeader.split(' ')[1];
|
||||
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
|
||||
if (err || user.role !== 'manager') {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
};
|
||||
router.use(authenticateJWT);
|
||||
|
||||
// --- START: Date Management Routes ---
|
||||
router.get('/enabled-dates', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.execute('SELECT YEAR(enabled_date) as year, MONTH(enabled_date) as month, DAY(enabled_date) as day FROM enabled_dates');
|
||||
// Format date safely using components from the database to avoid timezone shifts
|
||||
const dates = rows.map(r => `${r.year}-${String(r.month).padStart(2, '0')}-${String(r.day).padStart(2, '0')}`);
|
||||
res.json(dates);
|
||||
} catch (error) {
|
||||
console.error('Error fetching enabled dates:', error);
|
||||
res.status(500).json({ message: 'Database error fetching enabled dates.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Definitive version using a dedicated database connection
|
||||
router.post('/enabled-dates/update', async (req, res) => {
|
||||
let connection; // Define connection here to ensure it's accessible in the 'finally' block
|
||||
try {
|
||||
const { datesToEnable, datesToDisable } = req.body;
|
||||
|
||||
if (!Array.isArray(datesToEnable) || !Array.isArray(datesToDisable)) {
|
||||
return res.status(400).json({ message: 'Invalid input format.' });
|
||||
}
|
||||
|
||||
// 1. Get a single, dedicated connection from the pool
|
||||
connection = await db.getConnection();
|
||||
|
||||
// 2. Process all deletions sequentially on the dedicated connection
|
||||
for (const date of datesToDisable) {
|
||||
await connection.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]);
|
||||
}
|
||||
|
||||
// 3. Process all insertions sequentially on the dedicated connection
|
||||
for (const date of datesToEnable) {
|
||||
await connection.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Work schedule updated successfully.' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating work schedule:', error);
|
||||
res.status(500).json({ message: 'Database error during schedule update.' });
|
||||
} finally {
|
||||
// 4. Ensure the dedicated connection is always released back to the pool
|
||||
if (connection) {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
});
|
||||
// --- END: Date Management Routes ---
|
||||
|
||||
// --- ATTENDANCE & REPORTING ---
|
||||
|
||||
router.get('/failed-records', async (req, res) => {
|
||||
try {
|
||||
const { search = '', startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ message: 'Start date and end date are required.' });
|
||||
}
|
||||
|
||||
const searchTerm = `%${search}%`;
|
||||
const params = [startDate, `${endDate} 23:59:59`];
|
||||
|
||||
let searchQuery = '';
|
||||
if (search) {
|
||||
searchQuery = `AND (w.full_name LIKE ? OR w.department LIKE ?)`;
|
||||
params.push(searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT cr.worker_id, w.full_name, COUNT(*) as count
|
||||
FROM clock_records cr
|
||||
JOIN workers w ON cr.worker_id = w.id
|
||||
WHERE cr.event_type = 'failed'
|
||||
AND cr.timestamp BETWEEN ? AND ?
|
||||
${searchQuery}
|
||||
GROUP BY cr.worker_id, w.full_name
|
||||
ORDER BY count DESC
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('Failed records summary error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching failed records summary.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/failed-records/details', async (req, res) => {
|
||||
try {
|
||||
const { workerId, startDate, endDate } = req.query;
|
||||
if (!workerId || !startDate || !endDate) {
|
||||
return res.status(400).json({ message: 'Worker ID, start date, and end date are required.' });
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT cr.id, cr.timestamp, cr.event_type, COALESCE(qc.name, 'N/A') as qrCodeUsedName, cr.notes
|
||||
FROM clock_records cr
|
||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||
WHERE cr.worker_id = ?
|
||||
AND cr.event_type = 'failed'
|
||||
AND cr.timestamp BETWEEN ? AND ?
|
||||
ORDER BY cr.timestamp DESC
|
||||
`;
|
||||
|
||||
const params = [workerId, startDate, `${endDate} 23:59:59`];
|
||||
const [rows] = await db.execute(query, params);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('Failed records details error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching failed records details.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
|
||||
router.get('/attendance-records/export-raw', async (req, res) => {
|
||||
try {
|
||||
const { workerIds, startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ message: 'Start date and end date are required.' });
|
||||
}
|
||||
|
||||
let workerIdClause = '';
|
||||
const params = [startDate, `${endDate} 23:59:59`];
|
||||
|
||||
if (workerIds) {
|
||||
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
|
||||
if (idsArray.length > 0) {
|
||||
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT w.username, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes
|
||||
FROM clock_records cr
|
||||
JOIN workers w ON cr.worker_id = w.id
|
||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
|
||||
ORDER BY cr.timestamp DESC
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] });
|
||||
const csv = json2csvParser.parse(rows);
|
||||
res.header('Content-Type', 'text/csv').attachment(`raw_attendance_${startDate}_to_${endDate}.csv`).send(csv);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Raw attendance export error:', error);
|
||||
res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/add-record', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { workerId, eventType, timestamp, notes } = req.body
|
||||
|
||||
if (!workerId || !eventType || !timestamp) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'Worker ID, event type, and timestamp are required.' })
|
||||
}
|
||||
|
||||
// Check last event to prevent adding a duplicate event type
|
||||
const [lastEventRows] = await db.execute(
|
||||
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
|
||||
[workerId],
|
||||
)
|
||||
|
||||
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
|
||||
const status = eventType === 'clock_in' ? 'in' : 'out'
|
||||
return res.status(409).json({ message: `Worker is already clocked ${status}.` })
|
||||
}
|
||||
// --- THIS IS THE FIX ---
|
||||
const sanitizedTimestamp = timestamp.replace('T', ' ')
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)',
|
||||
[workerId, eventType, sanitizedTimestamp, notes],
|
||||
)
|
||||
|
||||
res.status(201).json({ message: 'Manual record added successfully.' })
|
||||
} catch (error) {
|
||||
console.error('Add manual record error:', error)
|
||||
res.status(500).json({ message: 'Database error adding manual record.' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/attendance-records/export', async (req, res) => {
|
||||
try {
|
||||
const { workerIds, startDate, endDate } = req.query;
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ message: 'Start date and end date are required.' });
|
||||
}
|
||||
|
||||
let workerIdClause = '';
|
||||
const params = [startDate, `${endDate} 23:59:59`];
|
||||
|
||||
if (workerIds) {
|
||||
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
|
||||
if (idsArray.length > 0) {
|
||||
workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`;
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT cr.worker_id, w.username, w.full_name, cr.event_type, cr.timestamp
|
||||
FROM clock_records cr
|
||||
JOIN workers w ON cr.worker_id = w.id
|
||||
WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause}
|
||||
ORDER BY cr.worker_id, cr.timestamp ASC
|
||||
`;
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
const workHoursByWorkerAndDay = {};
|
||||
|
||||
rows.forEach(row => {
|
||||
const day = new Date(row.timestamp).toISOString().split('T')[0];
|
||||
if (!workHoursByWorkerAndDay[row.worker_id]) {
|
||||
workHoursByWorkerAndDay[row.worker_id] = {
|
||||
username: row.username,
|
||||
full_name: row.full_name,
|
||||
days: {}
|
||||
};
|
||||
}
|
||||
if (!workHoursByWorkerAndDay[row.worker_id].days[day]) {
|
||||
workHoursByWorkerAndDay[row.worker_id].days[day] = [];
|
||||
}
|
||||
workHoursByWorkerAndDay[row.worker_id].days[day].push({
|
||||
type: row.event_type,
|
||||
time: new Date(row.timestamp)
|
||||
});
|
||||
});
|
||||
|
||||
const csvData = [];
|
||||
for (const workerId in workHoursByWorkerAndDay) {
|
||||
const workerData = workHoursByWorkerAndDay[workerId];
|
||||
for (const day in workerData.days) {
|
||||
const events = workerData.days[day];
|
||||
let dailyTotalSeconds = 0;
|
||||
let lastClockIn = null;
|
||||
|
||||
events.forEach(event => {
|
||||
if (event.type === 'clock_in') {
|
||||
lastClockIn = event.time;
|
||||
} else if (event.type === 'clock_out' && lastClockIn) {
|
||||
dailyTotalSeconds += (event.time - lastClockIn) / 1000;
|
||||
lastClockIn = null;
|
||||
}
|
||||
});
|
||||
|
||||
csvData.push({
|
||||
username: workerData.username,
|
||||
full_name: workerData.full_name,
|
||||
date: day,
|
||||
work_hours: (dailyTotalSeconds / 3600).toFixed(2)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const json2csvParser = new Parser({ fields: ['username', 'full_name', 'date', 'work_hours'] });
|
||||
const csv = json2csvParser.parse(csvData);
|
||||
res.header('Content-Type', 'text/csv').attachment(`work_hours_${startDate}_to_${endDate}.csv`).send(csv);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Work hours export error:', error);
|
||||
res.status(500).json({ message: 'Database error exporting work hours.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/attendance-records', async (req, res) => {
|
||||
try {
|
||||
const { workerIds, startDate, endDate, format } = req.query;
|
||||
if (!workerIds) {
|
||||
return res.status(400).json({ message: 'Worker IDs are required.' });
|
||||
}
|
||||
|
||||
// Ensure all IDs are numbers to prevent SQL injection.
|
||||
const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id));
|
||||
if (idsArray.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// --- MODIFICATION START ---
|
||||
// Instead of using a '?' placeholder for the IN clause, we build it directly.
|
||||
// This is safe because we have already sanitized idsArray to be only numbers.
|
||||
// This change is intended to bypass the specific bug in your MySQL version.
|
||||
const inClause = idsArray.join(',');
|
||||
let query = `
|
||||
SELECT cr.id, w.full_name, cr.event_type, cr.timestamp,
|
||||
COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName,
|
||||
cr.latitude, cr.longitude, cr.notes
|
||||
FROM clock_records cr
|
||||
JOIN workers w ON cr.worker_id = w.id
|
||||
LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id
|
||||
WHERE cr.worker_id IN (${inClause})`; // Placeholder is replaced here
|
||||
|
||||
const params = [];
|
||||
// --- MODIFICATION END ---
|
||||
|
||||
if (startDate && endDate) {
|
||||
query += ' AND cr.timestamp BETWEEN ? AND ?';
|
||||
const endOfDay = new Date(endDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
params.push(startDate, endOfDay);
|
||||
}
|
||||
query += ' ORDER BY w.full_name, cr.timestamp DESC';
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
|
||||
if (format === 'csv') {
|
||||
const json2csvParser = new Parser({ fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'] });
|
||||
const csv = json2csvParser.parse(rows);
|
||||
res.header('Content-Type', 'text/csv').attachment('attendance.csv').send(csv);
|
||||
} else {
|
||||
res.json(rows);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Attendance records error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching attendance records.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// --- All other manager routes remain the same ---
|
||||
|
||||
// GET all workers with filtering and pagination
|
||||
router.get('/workers', async (req, res) => {
|
||||
try {
|
||||
const { search = '', page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const searchTerm = `%${search}%`;
|
||||
|
||||
let baseQuery = `
|
||||
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at
|
||||
FROM workers w
|
||||
`;
|
||||
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
|
||||
|
||||
const params = [];
|
||||
const countParams = [];
|
||||
let whereClauses = ["w.role = 'worker'"];
|
||||
|
||||
if (search) {
|
||||
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
|
||||
params.push(searchTerm, searchTerm);
|
||||
countParams.push(searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
if (whereClauses.length > 0) {
|
||||
const whereString = ` WHERE ${whereClauses.join(' AND ')}`;
|
||||
baseQuery += whereString;
|
||||
countQuery += whereString;
|
||||
}
|
||||
|
||||
baseQuery += ` ORDER BY w.created_at DESC LIMIT ? OFFSET ?`;
|
||||
params.push(parseInt(limit), offset);
|
||||
|
||||
const [workers] = await db.execute(baseQuery, params);
|
||||
const [[{ totalCount }]] = await db.execute(countQuery, countParams);
|
||||
|
||||
res.json({ workers, totalCount });
|
||||
} catch (error) {
|
||||
console.error('Get workers error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching workers.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST (add) a new worker
|
||||
router.post('/workers', async (req, res) => {
|
||||
try {
|
||||
const { username, password, fullName, department, position, role = 'worker' } = req.body;
|
||||
if (!username || !password || !fullName) {
|
||||
return res.status(400).json({ message: 'Username, password, and full name are required.' });
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const [result] = await db.execute(
|
||||
'INSERT INTO workers (username, password_hash, full_name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[username, hashedPassword, fullName, role, department, position]
|
||||
);
|
||||
res.status(201).json({ id: result.insertId, username, fullName, role, department, position });
|
||||
} catch (error) {
|
||||
console.error('Add worker error:', error);
|
||||
if (error.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(409).json({ message: 'Username already exists.' });
|
||||
}
|
||||
res.status(500).json({ message: 'Database error adding worker.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE a worker
|
||||
router.delete('/workers/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [id]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Worker not found.' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete worker error:', error);
|
||||
res.status(500).json({ message: 'Database error deleting worker.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (update) a worker's password
|
||||
router.put('/workers/:workerId/password', async (req, res) => {
|
||||
try {
|
||||
const { workerId } = req.params;
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ message: 'Password must be at least 6 characters long.' });
|
||||
}
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
const [result] = await db.execute("UPDATE workers SET password_hash = ? WHERE id = ? AND role = 'worker'", [hashedPassword, workerId]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Worker not found.' });
|
||||
}
|
||||
res.status(200).json({ message: 'Password updated successfully.' });
|
||||
} catch (error) {
|
||||
console.error('Update password error:', error);
|
||||
res.status(500).json({ message: 'Database error updating password.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT (clear) a worker's device UUID
|
||||
router.put('/workers/:workerId/reset-device', async (req, res) => {
|
||||
try {
|
||||
const { workerId } = req.params;
|
||||
const [result] = await db.execute("UPDATE workers SET device_uuid = NULL WHERE id = ?", [workerId]);
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Worker not found.' });
|
||||
}
|
||||
res.status(200).json({ message: 'Device registration cleared.' });
|
||||
} catch (error) {
|
||||
console.error('Reset device error:', error);
|
||||
res.status(500).json({ message: 'Database error resetting device.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Geofence Management Routes
|
||||
router.get('/geofences', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
|
||||
);
|
||||
const geofences = rows.map(row => ({
|
||||
...row,
|
||||
coordinates: JSON.parse(row.coordinates || '[]')
|
||||
}));
|
||||
res.json(geofences);
|
||||
} catch (error) {
|
||||
console.error('Get geofences error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching geofences.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/geofences', async (req, res) => {
|
||||
try {
|
||||
const { name, coordinates } = req.body;
|
||||
if (!name || !coordinates) {
|
||||
return res.status(400).json({ message: 'Geofence name and coordinates are required.' });
|
||||
}
|
||||
|
||||
const [result] = await db.execute(
|
||||
'INSERT INTO geofences (name, coordinates, is_active) VALUES (?, ?, ?)',
|
||||
[name, JSON.stringify(coordinates), true]
|
||||
);
|
||||
|
||||
const newGeofence = {
|
||||
id: result.insertId,
|
||||
name,
|
||||
coordinates,
|
||||
is_active: true,
|
||||
};
|
||||
res.status(201).json(newGeofence);
|
||||
} catch (error) {
|
||||
console.error('Add geofence error:', error);
|
||||
res.status(500).json({ message: 'Database error adding geofence.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/geofences/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { is_active } = req.body;
|
||||
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return res.status(400).json({ message: 'is_active must be a boolean.' });
|
||||
}
|
||||
|
||||
const [result] = await db.execute(
|
||||
'UPDATE geofences SET is_active = ? WHERE id = ?',
|
||||
[is_active, id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Geofence not found.' });
|
||||
}
|
||||
|
||||
res.json({ id, is_active });
|
||||
} catch (error) {
|
||||
console.error('Update geofence error:', error);
|
||||
res.status(500).json({ message: 'Database error updating geofence.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/geofences/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'Geofence not found.' });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete geofence error:', error);
|
||||
res.status(500).json({ message: 'Database error deleting geofence.', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// QR Code Management Routes
|
||||
router.get('/qr-codes', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.execute(
|
||||
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('Get QR codes error:', error);
|
||||
res.status(500).json({ message: 'Database error fetching QR codes.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/qr-codes', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
|
||||
|
||||
const newQrCode = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
is_active: true
|
||||
};
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)',
|
||||
[newQrCode.id, newQrCode.name, newQrCode.is_active]
|
||||
);
|
||||
|
||||
res.status(201).json(newQrCode);
|
||||
} catch (error) {
|
||||
console.error('Add QR code error:', error);
|
||||
res.status(500).json({ message: 'Database error adding QR code.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/qr-codes/:id', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
// Handle both isActive (camelCase) and is_active (snake_case)
|
||||
const is_active = req.body.is_active ?? req.body.isActive;
|
||||
|
||||
if (typeof is_active !== 'boolean') {
|
||||
return res.status(400).json({ message: 'Status must be a boolean value.' });
|
||||
}
|
||||
|
||||
const [result] = await db.execute(
|
||||
'UPDATE qr_codes SET is_active = ? WHERE id = ?',
|
||||
[is_active, id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'QR Code not found.' });
|
||||
}
|
||||
|
||||
res.json({ id, is_active });
|
||||
} catch (error) {
|
||||
console.error('Update QR code error:', error);
|
||||
res.status(500).json({ message: 'Database error updating QR code.' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/qr-codes/:id', authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const [result] = await db.execute(
|
||||
'DELETE FROM qr_codes WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.affectedRows === 0) {
|
||||
return res.status(404).json({ message: 'QR Code not found.' });
|
||||
}
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete QR code error:', error);
|
||||
res.status(500).json({ message: 'Database error deleting QR code.' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
+67
-677
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**']),
|
||||
|
||||
@@ -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]]');
|
||||
@@ -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 */;
|
||||
Generated
+46
-8
@@ -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",
|
||||
|
||||
+6
-2
@@ -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",
|
||||
|
||||
+37
-12
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,653 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col lg:flex-row gap-8 attendance-reporting-layout">
|
||||
<div class="selection-panel w-full lg:w-1/2">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 h-full flex flex-col">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('selectWorkers') }}</h2>
|
||||
|
||||
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 mb-4">
|
||||
<div class="search-box relative flex-grow">
|
||||
<input type="text" v-model="searchQuery" :placeholder="$t('searchWorkerPlaceholder')"
|
||||
class="form-input w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
@keydown.enter.prevent="handleSearch" @keydown.down.prevent="navigateResults(1)"
|
||||
@keydown.up.prevent="navigateResults(-1)" @keydown.esc.prevent="clearSearch" />
|
||||
<div v-if="searchResults.length > 0"
|
||||
class="search-results-list absolute top-full left-0 right-0 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 border-t-0 rounded-b-md shadow-lg z-10 max-h-60 overflow-y-auto">
|
||||
<ul>
|
||||
<li v-for="(worker, index) in searchResults" :key="worker.id"
|
||||
:class="{ 'bg-blue-100 dark:bg-blue-900': index === highlightedIndex }"
|
||||
class="px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 border-b border-gray-100 dark:border-gray-700 last:border-b-0 text-gray-900 dark:text-white"
|
||||
@click="selectWorker(worker)" @mouseenter="highlightedIndex = index">
|
||||
{{ worker.full_name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handleSelectAll"
|
||||
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
|
||||
{{ $t('selectAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tag-selection mb-6">
|
||||
<div class="form-group flex flex-col gap-2">
|
||||
<label for="tag-select" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('addWorkersByTag') }}</label>
|
||||
<div class="selection-controls flex flex-col sm:flex-row gap-4 sm:gap-2 items-end">
|
||||
<select id="tag-select" v-model="selectedTagId"
|
||||
class="form-input w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option :value="null" disabled>{{ $t('chooseTag') }}</option>
|
||||
<option v-for="tag in allTags" :key="tag.id" :value="tag.id">
|
||||
{{ tag.tag_name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="addWorkersByTag" :disabled="!selectedTagId"
|
||||
class="button-secondary bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 w-full sm:w-auto">
|
||||
{{ $t('addByTag') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-workers-list flex-grow">
|
||||
<h4 class="text-lg font-semibold mb-3 text-gray-800 dark:text-white">
|
||||
{{ $t('selectedForReport', { count: selectedWorkers.length }) }}
|
||||
</h4>
|
||||
|
||||
<ul v-if="isSelectAllActive" class="space-y-2">
|
||||
<li
|
||||
class="flex justify-between items-center bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-4 py-2 text-gray-700 dark:text-gray-200">
|
||||
<span>{{ $t('allWorkersSelected', { count: selectedWorkers.length }) }}</span>
|
||||
<button @click="clearAllSelection"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<ul v-else-if="selectedWorkers.length > 0" class="flex flex-wrap gap-2">
|
||||
<li v-for="worker in selectedWorkers" :key="worker.id"
|
||||
class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-1 text-gray-700 dark:text-gray-200 text-sm font-medium">
|
||||
<span>{{ worker.full_name }}</span>
|
||||
<button @click="removeWorker(worker.id)"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-red-500 text-base leading-none">
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="empty-state text-gray-500 dark:text-gray-400 italic mt-4">
|
||||
{{ $t('noWorkersSelected') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="results-panel w-full lg:w-1/2">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('reportSettings') }}</h2>
|
||||
<div class="filters flex flex-col sm:flex-row gap-4 border-b border-gray-200 dark:border-gray-700 pb-6 mb-6">
|
||||
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate')
|
||||
}}</label>
|
||||
<input type="date" id="start-date" v-model="filters.startDate"
|
||||
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate')
|
||||
}}</label>
|
||||
<input type="date" id="end-date" v-model="filters.endDate"
|
||||
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overtime-settings-section flex flex-col gap-5" v-if="selectedWorkers.length > 0">
|
||||
<div class="worker-salaries border-b border-gray-200 dark:border-gray-700 pb-5">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-1">
|
||||
{{ $t('monthlySalary') }}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-3">
|
||||
{{ $t('salaryAppliedNote') }}
|
||||
</p>
|
||||
<div class="form-group flex flex-col gap-2">
|
||||
<input id="monthly-salary" type="number" v-model.number="monthlySalary"
|
||||
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="$t('salaryPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="factor-settings border-b border-gray-200 dark:border-gray-700 pb-5">
|
||||
<h4 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">{{ $t('otFactors') }}</h4>
|
||||
<div class="factor-input flex flex-col sm:flex-row gap-4">
|
||||
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('weekendFactor') }}</p>
|
||||
<input id="rest-day-factor" type="number" v-model.number="overtimeSettings.restDayFactor"
|
||||
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="form-group flex flex-col gap-2 w-full sm:w-1/2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('holidayFactor') }}</p>
|
||||
<input id="holiday-factor" type="number" v-model.number="overtimeSettings.publicHolidayFactor"
|
||||
class="form-input border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="holiday-picker">
|
||||
<h5 class="text-lg font-semibold text-gray-800 dark:text-white mb-3">
|
||||
{{ $t('selectPublicHolidays') }}
|
||||
</h5>
|
||||
<div class="calendar border border-gray-200 dark:border-gray-700 rounded-lg p-4 max-w-sm mx-auto">
|
||||
<div
|
||||
class="calendar-header flex justify-between items-center font-semibold mb-4 text-gray-800 dark:text-white">
|
||||
<button @click="changeMonth(-1)"
|
||||
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
|
||||
<
|
||||
</button>
|
||||
<span>{{ calendarGrid.monthName }} {{ calendarGrid.year }}</span>
|
||||
<button @click="changeMonth(1)"
|
||||
class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-white rounded-full w-8 h-8 flex items-center justify-center text-lg leading-none transition-colors duration-150">
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="calendar-weekdays grid grid-cols-7 text-center text-sm font-medium text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700 pb-2 mb-2">
|
||||
<div v-for="day in calendarGrid.weekdayLabels" :key="day" class="weekday">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-days grid grid-cols-7 text-center">
|
||||
<div v-for="(day, index) in calendarGrid.grid" :key="index"
|
||||
class="day-cell h-9 w-9 flex items-center justify-center rounded-full cursor-pointer transition-colors duration-100"
|
||||
:class="{
|
||||
'text-gray-400 dark:text-gray-500 cursor-default': day.type === 'padding',
|
||||
'bg-blue-600 text-white font-bold': day.isHoliday,
|
||||
'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-white':
|
||||
day.type === 'day' && !day.isHoliday,
|
||||
}" @click="day.type === 'day' && toggleHoliday(day.dateString)">
|
||||
<span v-if="day.type === 'day'">{{ day.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons mt-8 flex justify-end">
|
||||
<button @click="generateReport" :disabled="!canGenerate"
|
||||
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-6 py-3 rounded-md w-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200">
|
||||
{{ $t('generateReport') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8" v-if="reportGenerated">
|
||||
<div v-if="overtimeReport" class="overtime-report mb-8">
|
||||
<div class="report-header flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-2 sm:mb-0">
|
||||
{{ $t('overtimePaySummary') }}
|
||||
</h4>
|
||||
<button @click="exportOtSummaryCsv" :disabled="!overtimeReport"
|
||||
class="button-primary bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ $t('exportOtSummary') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-[400px] w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('worker') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('totalHoursWorked') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('totalOtPay') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="(report, name) in overtimeReport" :key="name"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ name }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ report.totalHours.toFixed(2) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ report.totalOtPay.toFixed(2) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="raw-logs" v-if="reportData.length > 0">
|
||||
<h4 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">
|
||||
{{ $t('rawAttendanceData') }}
|
||||
</h4>
|
||||
<div v-for="(group, workerName) in groupedReportData" :key="workerName" class="worker-group mb-6 last:mb-0">
|
||||
<h5
|
||||
class="worker-group-header bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md mb-3">
|
||||
{{ workerName }}
|
||||
</h5>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-[500px] w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('event') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('timsstamp') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('location') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('notes') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="record in group" :key="record.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block px-3 py-1 rounded-md text-xs font-semibold uppercase text-white" :class="{
|
||||
'bg-green-500': record.event_type === 'clock_in',
|
||||
'bg-red-500': record.event_type === 'clock_out',
|
||||
'bg-yellow-500': record.event_type === 'failed',
|
||||
}">
|
||||
{{ record.event_type.replace('_', ' ') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ new Date(record.timestamp).toLocaleString() }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ record.qrCodeUsedName }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ record.notes || $t('nA') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mt-8 text-center" v-if="loadingReport">
|
||||
<p class="text-lg font-medium text-gray-700 dark:text-gray-200">{{ $t('loadingReport') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
// --- STATE ---
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const highlightedIndex = ref(-1)
|
||||
const isSelectAllActive = ref(false)
|
||||
|
||||
const selectedWorkers = ref([])
|
||||
const filters = ref({ startDate: '', endDate: '' })
|
||||
|
||||
// NEW: State for tag selection
|
||||
const allTags = ref([])
|
||||
const selectedTagId = ref(null)
|
||||
|
||||
const loadingReport = ref(false)
|
||||
const reportGenerated = ref(false)
|
||||
const reportData = ref([])
|
||||
const overtimeReport = ref(null)
|
||||
|
||||
// --- OT & SALARY STATE ---
|
||||
const monthlySalary = ref(null)
|
||||
const overtimeSettings = ref({
|
||||
restDayFactor: 2,
|
||||
publicHolidayFactor: 3,
|
||||
publicHolidays: [],
|
||||
})
|
||||
|
||||
// --- CALENDAR STATE ---
|
||||
const calendarDate = ref(new Date())
|
||||
|
||||
// --- COMPUTED ---
|
||||
const canGenerate = computed(() => {
|
||||
return (
|
||||
selectedWorkers.value.length > 0 &&
|
||||
filters.value.startDate &&
|
||||
filters.value.endDate &&
|
||||
monthlySalary.value &&
|
||||
monthlySalary.value > 0
|
||||
)
|
||||
})
|
||||
|
||||
const groupedReportData = computed(() => {
|
||||
return reportData.value.reduce((groups, record) => {
|
||||
const key = record.full_name
|
||||
if (!groups[key]) {
|
||||
groups[key] = []
|
||||
}
|
||||
groups[key].push(record)
|
||||
return groups
|
||||
}, {})
|
||||
})
|
||||
|
||||
const calendarGrid = computed(() => {
|
||||
const year = calendarDate.value.getFullYear()
|
||||
const month = calendarDate.value.getMonth()
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay() // 0=Sun
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const grid = []
|
||||
const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
grid.push({ type: 'padding' })
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
grid.push({
|
||||
type: 'day',
|
||||
date: day,
|
||||
dateString: dateString,
|
||||
isHoliday: overtimeSettings.value.publicHolidays.includes(dateString),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
grid,
|
||||
weekdayLabels,
|
||||
monthName: calendarDate.value.toLocaleString('default', { month: 'long' }),
|
||||
year,
|
||||
}
|
||||
})
|
||||
|
||||
// --- METHODS ---
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
// Setup default dates
|
||||
const today = new Date()
|
||||
filters.value.endDate = today.toISOString().split('T')[0]
|
||||
const sevenDaysAgo = new Date()
|
||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
|
||||
calendarDate.value = new Date(filters.value.startDate + 'T00:00:00')
|
||||
|
||||
// NEW: Fetch all available tags
|
||||
try {
|
||||
allTags.value = await apiFetch('/api/managers/tags')
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tags', err)
|
||||
}
|
||||
}
|
||||
|
||||
const addWorkersByTag = async () => {
|
||||
if (!selectedTagId.value) return
|
||||
try {
|
||||
// Use the API to get all workers with the selected tag
|
||||
const data = await apiFetch(`/api/managers/workers?tags=${selectedTagId.value}&limit=1000`)
|
||||
|
||||
// Add the fetched workers to the selection list, avoiding duplicates
|
||||
data.workers.forEach((worker) => {
|
||||
if (!selectedWorkers.value.some((sw) => sw.id === worker.id)) {
|
||||
selectedWorkers.value.push(worker)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset the dropdown
|
||||
selectedTagId.value = null
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch workers by tag', err)
|
||||
alert($t('tagLoadError'));
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
|
||||
selectWorker(searchResults.value[highlightedIndex.value])
|
||||
} else if (searchQuery.value === '') {
|
||||
await handleSelectAll()
|
||||
searchResults.value = []
|
||||
highlightedIndex.value = -1
|
||||
} else {
|
||||
await fetchWorkers()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkers = async (selectAll = false) => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&limit=1000`)
|
||||
if (selectAll) {
|
||||
data.workers.forEach((worker) => {
|
||||
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
|
||||
selectedWorkers.value.push(worker)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
searchResults.value = data.workers
|
||||
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to search workers', err)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
|
||||
const navigateResults = (direction) => {
|
||||
if (searchResults.value.length === 0) return
|
||||
const newIndex = highlightedIndex.value + direction
|
||||
if (newIndex >= 0 && newIndex < searchResults.value.length) {
|
||||
highlightedIndex.value = newIndex
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
await fetchWorkers(true)
|
||||
isSelectAllActive.value = true
|
||||
}
|
||||
|
||||
const clearAllSelection = () => {
|
||||
selectedWorkers.value = []
|
||||
isSelectAllActive.value = false
|
||||
}
|
||||
|
||||
const selectWorker = (worker) => {
|
||||
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
|
||||
selectedWorkers.value.push(worker)
|
||||
}
|
||||
isSelectAllActive.value = false
|
||||
clearSearch()
|
||||
}
|
||||
|
||||
const removeWorker = (workerId) => {
|
||||
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
|
||||
isSelectAllActive.value = false
|
||||
}
|
||||
|
||||
const generateReport = async () => {
|
||||
if (!canGenerate.value) {
|
||||
alert($t('generateReportError'));
|
||||
return
|
||||
}
|
||||
loadingReport.value = true
|
||||
reportGenerated.value = false
|
||||
reportData.value = []
|
||||
overtimeReport.value = null
|
||||
|
||||
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
|
||||
const url = `/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
|
||||
|
||||
try {
|
||||
const fetchedRecords = await apiFetch(url)
|
||||
reportData.value = fetchedRecords
|
||||
|
||||
const otResults = {}
|
||||
const hourlyRate = monthlySalary.value / 26 / 8
|
||||
|
||||
for (const worker of selectedWorkers.value) {
|
||||
const workerRecords = fetchedRecords
|
||||
.filter((r) => r.full_name === worker.full_name)
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
|
||||
const dailyBreakdown = []
|
||||
let clockInTime = null
|
||||
|
||||
for (const record of workerRecords) {
|
||||
if (record.event_type === 'clock_in' && !clockInTime) {
|
||||
clockInTime = new Date(record.timestamp)
|
||||
} else if (record.event_type === 'clock_out' && clockInTime) {
|
||||
const clockOutTime = new Date(record.timestamp)
|
||||
const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const date = clockInTime.toISOString().split('T')[0]
|
||||
const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date)
|
||||
const factor = isPublicHoliday
|
||||
? overtimeSettings.value.publicHolidayFactor
|
||||
: overtimeSettings.value.restDayFactor
|
||||
const otPay = hours * hourlyRate * factor
|
||||
dailyBreakdown.push({ hours, otPay })
|
||||
}
|
||||
clockInTime = null
|
||||
}
|
||||
}
|
||||
|
||||
const totalHours = dailyBreakdown.reduce((sum, day) => sum + day.hours, 0)
|
||||
const totalOtPay = dailyBreakdown.reduce((sum, day) => sum + day.otPay, 0)
|
||||
otResults[worker.full_name] = { totalHours, totalOtPay }
|
||||
}
|
||||
overtimeReport.value = otResults
|
||||
reportGenerated.value = true
|
||||
} catch (err) {
|
||||
console.error('Failed to generate report', err)
|
||||
alert($t('reportGenerationError'));
|
||||
} finally {
|
||||
loadingReport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportOtSummaryCsv = () => {
|
||||
if (reportData.value.length === 0) return
|
||||
|
||||
const headers = [
|
||||
'Worker Name',
|
||||
'Event',
|
||||
'Timestamp',
|
||||
'Location',
|
||||
'Session Hours',
|
||||
'Session OT Wage (RM)',
|
||||
]
|
||||
const allRows = []
|
||||
const hourlyRate = monthlySalary.value / 26 / 8
|
||||
|
||||
for (const worker of selectedWorkers.value) {
|
||||
const workerRecords = reportData.value
|
||||
.filter((r) => r.full_name === worker.full_name)
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
|
||||
let clockInRecord = null
|
||||
let workerTotalWage = 0
|
||||
|
||||
for (const record of workerRecords) {
|
||||
const eventType = record.event_type === 'clock_in' ? 'Clock In' : 'Clock Out'
|
||||
const timestamp = new Date(record.timestamp).toLocaleString()
|
||||
let row = [
|
||||
`${worker.full_name}`,
|
||||
`${eventType}`,
|
||||
`${timestamp}`,
|
||||
`${record.qrCodeUsedName}`,
|
||||
]
|
||||
|
||||
if (record.event_type === 'clock_in') {
|
||||
if (!clockInRecord) {
|
||||
clockInRecord = record
|
||||
}
|
||||
row.push('', '')
|
||||
allRows.push(row.join(','))
|
||||
} else if (record.event_type === 'clock_out') {
|
||||
if (clockInRecord) {
|
||||
const clockOutTime = new Date(record.timestamp)
|
||||
const clockInTime = new Date(clockInRecord.timestamp)
|
||||
const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60)
|
||||
|
||||
if (hours > 0) {
|
||||
const date = clockInTime.toISOString().split('T')[0]
|
||||
const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date)
|
||||
const factor = isPublicHoliday
|
||||
? overtimeSettings.value.publicHolidayFactor
|
||||
: overtimeSettings.value.restDayFactor
|
||||
const otPay = hours * hourlyRate * factor
|
||||
workerTotalWage += otPay
|
||||
|
||||
row.push(hours.toFixed(2), otPay.toFixed(2))
|
||||
} else {
|
||||
row.push('0.00', '0.00')
|
||||
}
|
||||
clockInRecord = null
|
||||
} else {
|
||||
row.push('N/A', 'N/A')
|
||||
}
|
||||
allRows.push(row.join(','))
|
||||
}
|
||||
}
|
||||
|
||||
const summaryRow = [`${worker.full_name} Total`, '', '', '', '', workerTotalWage.toFixed(2)]
|
||||
allRows.push(summaryRow.join(','))
|
||||
|
||||
allRows.push('')
|
||||
}
|
||||
|
||||
if (allRows.length > 0) {
|
||||
allRows.pop()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-')
|
||||
const fileName = `${timestamp}_report.csv`
|
||||
|
||||
let csvContent = headers.join(',') + '\n' + allRows.join('\n')
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.setAttribute('href', url)
|
||||
link.setAttribute('download', fileName)
|
||||
link.style.visibility = 'hidden'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const changeMonth = (offset) => {
|
||||
const newDate = new Date(calendarDate.value)
|
||||
newDate.setMonth(newDate.getMonth() + offset)
|
||||
calendarDate.value = newDate
|
||||
}
|
||||
|
||||
const toggleHoliday = (dateString) => {
|
||||
const index = overtimeSettings.value.publicHolidays.indexOf(dateString)
|
||||
if (index === -1) {
|
||||
overtimeSettings.value.publicHolidays.push(dateString)
|
||||
} else {
|
||||
overtimeSettings.value.publicHolidays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchInitialData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No custom styles needed anymore, Tailwind handles everything */
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('createGeofence') }}</h2>
|
||||
<div id="map-container" class="w-full h-96 rounded-lg border border-gray-300 dark:border-gray-600 mb-4 z-0">
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ $t('drawInstruction') }}</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row items-end gap-4">
|
||||
<div class="flex-grow w-full">
|
||||
<label for="geofence-name" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{
|
||||
$t('geofenceName') }}</label>
|
||||
<input type="text" id="geofence-name" v-model="newGeofenceName" :placeholder="$t('geofenceNamePlaceholder')"
|
||||
class="mt-1 border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-full" />
|
||||
</div>
|
||||
<button @click="startOver" v-if="newGeofenceCoords"
|
||||
class="bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto">
|
||||
{{ $t('startOver') }}
|
||||
</button>
|
||||
<button @click="saveGeofence" :disabled="!canSave"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
|
||||
{{ $t('saveGeofence') }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('existingGeofences') }}</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-[600px] w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('status') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
|
||||
{{ $t('actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="fence in geofences" :key="fence.id"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ fence.name }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-block px-3 py-1 rounded-full text-xs font-semibold uppercase" :class="fence.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-200'
|
||||
">
|
||||
{{ $t(fence.is_active ? 'active' : 'inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||
<button @click="viewGeofenceOnMap(fence)"
|
||||
class="flex items-center gap-1 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
<span class="text-base">🗺️</span> {{ $t('view') }}
|
||||
</button>
|
||||
<button @click="toggleGeofenceStatus(fence)"
|
||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t(fence.is_active ? 'deactivate' : 'activate') }}
|
||||
</button>
|
||||
<button @click="deleteGeofence(fence.id)"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="geofences.length === 0">
|
||||
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">{{ $t('noGeofencesFound') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
import { apiFetch } from '@/api.js';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet-draw/dist/leaflet.draw.css';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet-draw';
|
||||
|
||||
const geofences = ref([]);
|
||||
const newGeofenceName = ref('');
|
||||
const newGeofenceCoords = ref(null);
|
||||
let map = null;
|
||||
let drawnItems = null;
|
||||
const fenceLayers = {};
|
||||
|
||||
const canSave = computed(() => newGeofenceName.value && newGeofenceCoords.value);
|
||||
|
||||
// START: Added Function
|
||||
const startOver = () => {
|
||||
drawnItems.clearLayers();
|
||||
newGeofenceCoords.value = null;
|
||||
newGeofenceName.value = '';
|
||||
};
|
||||
// END: Added Function
|
||||
|
||||
const fetchGeofences = async () => {
|
||||
try {
|
||||
geofences.value = await apiFetch('/api/managers/geofences');
|
||||
displayGeofencesOnMap();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch geofences:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initMap = () => {
|
||||
if (map) return;
|
||||
map = L.map('map-container').setView([23.1291, 113.2644], 11);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
drawnItems = new L.FeatureGroup();
|
||||
map.addLayer(drawnItems);
|
||||
|
||||
const drawControl = new L.Control.Draw({
|
||||
edit: { featureGroup: drawnItems, edit: false, remove: false },
|
||||
draw: {
|
||||
polygon: {
|
||||
allowIntersection: false,
|
||||
showArea: false
|
||||
},
|
||||
polyline: false, rectangle: false, circle: false, marker: false, circlemarker: false
|
||||
}
|
||||
});
|
||||
map.addControl(drawControl);
|
||||
|
||||
map.on(L.Draw.Event.CREATED, (event) => {
|
||||
drawnItems.clearLayers(); // Clear previous unsaved drawings
|
||||
const layer = event.layer;
|
||||
drawnItems.addLayer(layer);
|
||||
const latLngs = layer.getLatLngs()[0];
|
||||
const closedLatLngs = [...latLngs, latLngs[0]];
|
||||
newGeofenceCoords.value = closedLatLngs.map(latlng => [latlng.lng, latlng.lat]);
|
||||
});
|
||||
};
|
||||
|
||||
const displayGeofencesOnMap = () => {
|
||||
if (!map) return;
|
||||
// Clear existing fence layers before redrawing
|
||||
Object.values(fenceLayers).forEach(layer => map.removeLayer(layer));
|
||||
|
||||
geofences.value.forEach(fence => {
|
||||
const leafletCoords = fence.coordinates.map(coord => [coord[1], coord[0]]);
|
||||
const polygon = L.polygon(leafletCoords, {
|
||||
color: fence.is_active ? '#3388ff' : '#888888',
|
||||
weight: 3,
|
||||
}).bindPopup(`<b>${fence.name}</b>`);
|
||||
polygon.addTo(map);
|
||||
fenceLayers[fence.id] = polygon;
|
||||
});
|
||||
};
|
||||
|
||||
const saveGeofence = async () => {
|
||||
if (!canSave.value) return;
|
||||
try {
|
||||
const newFence = await apiFetch('/api/managers/geofences', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: newGeofenceName.value, coordinates: newGeofenceCoords.value })
|
||||
});
|
||||
geofences.value.unshift(newFence);
|
||||
startOver(); // Use startOver to clear the form
|
||||
displayGeofencesOnMap();
|
||||
} catch (error) {
|
||||
console.error('Failed to save geofence:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGeofence = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this geofence?')) return;
|
||||
try {
|
||||
await apiFetch(`/api/managers/geofences/${id}`, { method: 'DELETE' });
|
||||
if (fenceLayers[id]) {
|
||||
map.removeLayer(fenceLayers[id]);
|
||||
delete fenceLayers[id];
|
||||
}
|
||||
geofences.value = geofences.value.filter(g => g.id !== id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete geofence:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGeofenceStatus = async (fence) => {
|
||||
try {
|
||||
const updatedFence = await apiFetch(`/api/managers/geofences/${fence.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: !fence.is_active })
|
||||
});
|
||||
const index = geofences.value.findIndex(f => f.id === fence.id);
|
||||
if (index !== -1) {
|
||||
geofences.value[index].is_active = updatedFence.is_active;
|
||||
}
|
||||
if (fenceLayers[fence.id]) {
|
||||
fenceLayers[fence.id].setStyle({ color: updatedFence.is_active ? '#3388ff' : '#888888' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle geofence status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const viewGeofenceOnMap = (fence) => {
|
||||
if (fenceLayers[fence.id]) {
|
||||
map.fitBounds(fenceLayers[fence.id].getBounds(), { padding: [50, 50] });
|
||||
fenceLayers[fence.id].openPopup();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initMap();
|
||||
fetchGeofences();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-4 gap-8 items-start">
|
||||
<div class="xl:col-span-3 bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-800 dark:text-white">
|
||||
{{ monthYear }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="prevMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
|
||||
</button>
|
||||
<button @click="nextMonth" class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-sm font-semibold text-gray-500 dark:text-gray-400 mb-2">
|
||||
<div v-for="day in weekDays" :key="day" class="py-2">{{ day }}</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<div v-for="day in calendarGrid" :key="day.id"
|
||||
@click="day.isCurrentMonth && onDayClick(day)"
|
||||
:class="getDayClasses(day)">
|
||||
{{ day.date }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 sticky top-4">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800 dark:text-white">{{ $t('pendingChanges') }}</h3>
|
||||
<div v-if="!hasPendingChanges" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{{ $t('noPendingChanges') }}
|
||||
</div>
|
||||
<div v-else class="space-y-4 max-h-80 overflow-y-auto pr-2">
|
||||
<div v-if="datesToEnable.size > 0">
|
||||
<h4 class="font-semibold text-green-600 dark:text-green-400 mb-2">{{ $t('datesToEnable') }}</h4>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="date in sortedEnableList" :key="date" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDate(date) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="datesToDisable.size > 0">
|
||||
<h4 class="font-semibold text-red-600 dark:text-red-400 mb-2">{{ $t('datesToDisable') }}</h4>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="date in sortedDisableList" :key="date" class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ formatDate(date) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-3">
|
||||
<button @click="applyChanges" :disabled="!hasPendingChanges" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ $t('applyChanges') }}
|
||||
</button>
|
||||
<button @click="discardChanges" :disabled="!hasPendingChanges" class="w-full bg-gray-500 hover:bg-gray-600 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ $t('discardChanges') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { apiFetch } from '@/api.js';
|
||||
|
||||
const viewDate = ref(new Date());
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const originalEnabledDates = ref(new Set());
|
||||
const datesToEnable = ref(new Set());
|
||||
const datesToDisable = ref(new Set());
|
||||
|
||||
const hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0);
|
||||
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort());
|
||||
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort());
|
||||
|
||||
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' }));
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
const calendarGrid = computed(() => {
|
||||
const year = viewDate.value.getFullYear();
|
||||
const month = viewDate.value.getMonth();
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const grid = [];
|
||||
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
grid.push({ id: `prev-${i}`, isCurrentMonth: false });
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
||||
grid.push({ id: dateStr, date: i, isCurrentMonth: true });
|
||||
}
|
||||
|
||||
return grid;
|
||||
});
|
||||
|
||||
const getDayClasses = (day) => {
|
||||
if (!day.isCurrentMonth) return 'h-20';
|
||||
|
||||
const dateStr = day.id;
|
||||
const classes = ['h-20', 'flex', 'items-center', 'justify-center', 'text-lg', 'rounded-lg', 'cursor-pointer', 'transition-colors', 'relative'];
|
||||
|
||||
let isEnabled = originalEnabledDates.value.has(dateStr);
|
||||
if (datesToEnable.value.has(dateStr)) isEnabled = true;
|
||||
if (datesToDisable.value.has(dateStr)) isEnabled = false;
|
||||
|
||||
const isPendingEnable = datesToEnable.value.has(dateStr);
|
||||
const isPendingDisable = datesToDisable.value.has(dateStr);
|
||||
|
||||
if (isPendingEnable) {
|
||||
classes.push('bg-blue-500', 'text-white', 'font-bold');
|
||||
} else if (isPendingDisable) {
|
||||
classes.push('bg-red-200', 'dark:bg-red-800', 'text-red-700', 'dark:text-red-200');
|
||||
classes.push('after:content-[\'\']', 'after:absolute', 'after:w-3/4', 'after:h-0.5', 'after:bg-red-500', 'after:left-1/2', 'after:top-1/2', 'after:-translate-x-1/2', 'after:-translate-y-1/2', 'after:rotate-[-10deg]');
|
||||
} else if (isEnabled) {
|
||||
classes.push('bg-green-100', 'dark:bg-green-800', 'text-green-800', 'dark:text-green-200');
|
||||
} else {
|
||||
classes.push('bg-white', 'dark:bg-gray-800', 'hover:bg-gray-100', 'dark:hover:bg-gray-700');
|
||||
}
|
||||
|
||||
// Add a yellow ring for today's date
|
||||
if (dateStr === todayStr) {
|
||||
classes.push('ring-2', 'ring-yellow-400', 'dark:ring-yellow-500');
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
|
||||
function onDayClick(day) {
|
||||
const dateStr = day.id;
|
||||
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
|
||||
|
||||
if (isOriginallyEnabled) {
|
||||
datesToDisable.value.has(dateStr)
|
||||
? datesToDisable.value.delete(dateStr)
|
||||
: datesToDisable.value.add(dateStr);
|
||||
} else {
|
||||
datesToEnable.value.has(dateStr)
|
||||
? datesToEnable.value.delete(dateStr)
|
||||
: datesToEnable.value.add(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyChanges() {
|
||||
if (!confirm('Are you sure you want to apply these changes to the work schedule?')) return;
|
||||
|
||||
try {
|
||||
await apiFetch('/api/managers/enabled-dates/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
datesToEnable: Array.from(datesToEnable.value),
|
||||
datesToDisable: Array.from(datesToDisable.value),
|
||||
}),
|
||||
});
|
||||
await fetchEnabledDates();
|
||||
discardChanges();
|
||||
alert('Work schedule updated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to apply changes:', error);
|
||||
alert('Failed to update schedule. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
function discardChanges() {
|
||||
datesToEnable.value.clear();
|
||||
datesToDisable.value.clear();
|
||||
}
|
||||
|
||||
const prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1));
|
||||
const nextMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1));
|
||||
|
||||
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||
});
|
||||
|
||||
async function fetchEnabledDates() {
|
||||
try {
|
||||
const dates = await apiFetch('/api/managers/enabled-dates');
|
||||
originalEnabledDates.value = new Set(dates);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch enabled dates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchEnabledDates);
|
||||
</script>
|
||||
@@ -2,92 +2,56 @@
|
||||
<div class="flex flex-col gap-8 pb-20">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('addNewUser') }}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 items-end">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 items-end">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName')
|
||||
}}</label>
|
||||
<input type="text" id="fullName" v-model="newWorker.fullName"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="$t('egJohnSmith')" />
|
||||
<label for="fullName" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('fullName') }}</label>
|
||||
<input type="text" id="fullName" v-model="newWorker.fullName" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJohnSmith')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('username')
|
||||
}}</label>
|
||||
<input type="text" id="username" v-model="newWorker.username"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="$t('egJsmith')" />
|
||||
<label for="username" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('username') }}</label>
|
||||
<input type="text" id="username" v-model="newWorker.username" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egJsmith')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300"> {{ $t('password')
|
||||
}}</label>
|
||||
<input type="password" id="password" v-model="newWorker.password"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="$t('eg123456')" />
|
||||
<label for="password" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('password') }}</label>
|
||||
<input type="password" id="password" v-model="newWorker.password" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('eg123456')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="department" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('department') }}</label>
|
||||
<input type="text" id="department" v-model="newWorker.department" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egSales')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="position" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('position') }}</label>
|
||||
<input type="text" id="position" v-model="newWorker.position" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" :placeholder="$t('egManager')" />
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<label class="flex items-center text-sm mb-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="isManager"
|
||||
class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
|
||||
<input type="checkbox" v-model="isManager" class="form-checkbox h-4 w-4 text-blue-600 rounded mr-2 focus:ring-blue-500" />
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span>
|
||||
</label>
|
||||
<button @click="addWorker" :disabled="!isFormValid || loading"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<button @click="addWorker" :disabled="!isFormValid || loading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{{ loading ? $t('adding') : $t('addUser') }}
|
||||
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-red-500 text-sm mt-4">{{ errorMessage }}</p>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('manageTags') }}</h2>
|
||||
<div class="flex flex-col sm:flex-row items-end gap-4 mb-4">
|
||||
<div class="flex flex-col gap-2 flex-grow w-full sm:w-auto">
|
||||
<label for="new-tag" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('createNewTag')
|
||||
}}</label>
|
||||
<input type="text" id="new-tag" v-model="newTagName" @keyup.enter="createTag"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
:placeholder="$t('egTeam')" />
|
||||
</div>
|
||||
<button @click="createTag" :disabled="!newTagName"
|
||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md disabled:opacity-50 disabled:cursor-not-allowed w-full sm:w-auto">
|
||||
{{ $t('createTag') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 min-h-[2rem]">
|
||||
<span v-for="tag in allTags" :key="tag.id"
|
||||
class="bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1">
|
||||
{{ tag.tag_name }}
|
||||
<button @click="deleteTag(tag.id)" class="text-red-500 hover:text-red-700 ml-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:gap-0 sm:items-center justify-between">
|
||||
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrUsername')"
|
||||
class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 mr-1">{{ $t('filterByTag') }}</span>
|
||||
<button v-for="tag in allTags" :key="tag.id" @click="toggleTagFilter(tag.id)" :class="{
|
||||
'bg-blue-600 text-white': selectedTagIds.includes(tag.id),
|
||||
'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white hover:bg-gray-300 dark:hover:bg-gray-600':
|
||||
!selectedTagIds.includes(tag.id),
|
||||
}" class="px-3 py-1 rounded-full text-sm font-medium transition-colors duration-200">
|
||||
{{ tag.tag_name }}
|
||||
</button>
|
||||
<button v-if="selectedTagIds.length > 0" @click="clearTagFilter"
|
||||
class="text-blue-600 hover:text-blue-800 text-sm font-medium ml-2">
|
||||
{{ $t('clearFilter') }}
|
||||
</button>
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between">
|
||||
<input type="text" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full sm:max-w-xs focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="export-start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('startDate') }}</label>
|
||||
<input type="date" id="export-start-date" v-model="exportFilters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="export-end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ $t('endDate') }}</label>
|
||||
<input type="date" id="export-end-date" v-model="exportFilters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="self-end bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
@@ -95,529 +59,259 @@
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||
<th class="w-12 px-2 py-3 text-center">
|
||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected"
|
||||
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('fullName') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('username') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('tags') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">
|
||||
{{ $t('dateJoined') }}
|
||||
</th>
|
||||
<th
|
||||
class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">
|
||||
{{ $t('actions') }}
|
||||
<input type="checkbox" @change="toggleSelectAll" :checked="isAllSelected" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('fullName') }}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('username') }}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('department') }}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('position') }}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider">{{ $t('dateJoined') }}</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-right">{{ $t('actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="worker in workers" :key="worker.id"
|
||||
:class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }"
|
||||
class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<tr v-for="worker in workers" :key="worker.id" :class="{ 'bg-blue-50 dark:bg-blue-950': isWorkerSelected(worker.id) }" class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150">
|
||||
<td class="px-2 py-3 text-center">
|
||||
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)"
|
||||
class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
<input type="checkbox" :checked="isWorkerSelected(worker.id)" @change="toggleWorkerSelection(worker.id)" class="form-checkbox h-4 w-4 text-blue-600 rounded" />
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.full_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<template v-if="worker.tags">
|
||||
<span v-for="tag in worker.tags.split(', ')" :key="tag"
|
||||
class="bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 px-2 py-1 rounded-full text-xs font-medium mr-1 mb-1 inline-block">
|
||||
{{ tag }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">{{ $t('nA') }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">
|
||||
{{ new Date(worker.created_at).toLocaleDateString() }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.department }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.position }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
|
||||
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
|
||||
<button @click="openTagEditor(worker)"
|
||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t('editTags') }}
|
||||
</button>
|
||||
<button @click="openPasswordModal(worker)"
|
||||
class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t('password') }}
|
||||
</button>
|
||||
<button @click="viewRecords(worker.id)"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t('viewRecords') }}
|
||||
</button>
|
||||
<button @click="deleteWorker(worker.id)"
|
||||
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
<button @click="openPasswordModal(worker)" class="bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('password') }}</button>
|
||||
<button @click="viewRecords(worker.id)" class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('viewRecords') }}</button>
|
||||
<button @click="clearDevice(worker.id)" class="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('clearDevice') }}</button>
|
||||
<button @click="deleteWorker(worker.id)" class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200">{{ $t('delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="workers.length === 0">
|
||||
<td colspan="6" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<td colspan="7" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="totalPages > 1"
|
||||
class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
|
||||
{{ $t('previous') }}
|
||||
</button>
|
||||
<span class="text-gray-700 dark:text-gray-200">
|
||||
{{ $t('pageOf', { current: currentPage, total: totalPages }) }}
|
||||
</span>
|
||||
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages"
|
||||
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">
|
||||
{{ $t('next') }}
|
||||
</button>
|
||||
<div v-if="totalPages > 1" class="flex justify-end items-center gap-4 mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('previous') }}</button>
|
||||
<span class="text-gray-700 dark:text-gray-200">{{ $t('pageOf', { current: currentPage, total: totalPages }) }}</span>
|
||||
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-800 dark:text-white">{{ $t('next') }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="isEditingTags" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||
<div
|
||||
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md transform transition-all scale-100 opacity-100">
|
||||
<h3 class="text-2xl font-bold mb-6 text-gray-800 dark:text-white">{{ editorTitle }}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||
<label v-for="tag in allTags" :key="tag.id"
|
||||
class="flex items-center gap-3 cursor-pointer text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 p-3 rounded-lg transition-all duration-200 ease-in-out border border-transparent hover:border-blue-300 dark:hover:border-blue-700">
|
||||
<div class="relative flex items-center">
|
||||
<input type="checkbox" :checked="isTagAppliedToSelection(tag.id)" @change="toggleTagForSelection(tag.id)"
|
||||
class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-400 text-blue-600 transition-all checked:border-blue-600 checked:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-500 dark:checked:border-blue-500 dark:checked:bg-blue-500 dark:focus:ring-blue-400" />
|
||||
<span
|
||||
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-white opacity-0 transition-opacity peer-checked:opacity-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"
|
||||
stroke="currentColor" stroke-width="1">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-base font-medium select-none">{{ tag.tag_name }}</span>
|
||||
</label>
|
||||
<p v-if="allTags.length === 0">
|
||||
{{ $t('noTagsAvailable') }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="closeTagEditor"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-5 py-2 rounded-md mt-8 w-full transition-colors duration-200">
|
||||
{{ $t('done') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPasswordModalVisible"
|
||||
class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||
<div v-if="isPasswordModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h3 class="text-2xl font-bold mb-2 text-gray-800 dark:text-white">{{ $t('changePassword') }}</h3>
|
||||
<p v-if="editingWorkerPassword" class="mb-6 text-gray-600 dark:text-gray-300">
|
||||
For user: <span class="font-semibold">{{ $t('forUser') }}: ...</span>
|
||||
{{ $t('forUser') }}: <span class="font-semibold">{{ editingWorkerPassword.full_name }}</span>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="updateWorkerPassword">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
|
||||
$t('newPassword') }}</label>
|
||||
<input type="password" id="newPassword" v-model="newPassword" required
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
<label for="newPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('newPassword') }}</label>
|
||||
<input type="password" id="newPassword" v-model="newPassword" required class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{
|
||||
$t('confirmNewPassword') }}</label>
|
||||
<input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required
|
||||
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
<label for="confirmNewPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('confirmNewPassword') }}</label>
|
||||
<input type="password" id="confirmNewPassword" v-model="confirmNewPassword" required class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">
|
||||
{{ passwordErrorMessage }}
|
||||
</p>
|
||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">
|
||||
{{ passwordSuccessMessage }}
|
||||
</p>
|
||||
<p v-if="passwordErrorMessage" class="text-red-500 text-sm -mt-2">{{ passwordErrorMessage }}</p>
|
||||
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm -mt-2">{{ passwordSuccessMessage }}</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-4 mt-8">
|
||||
<button type="button" @click="closePasswordModal"
|
||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors">
|
||||
{{ $t('cancel') }}
|
||||
|
||||
</button>
|
||||
<button type="submit" :disabled="passwordLoading"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
|
||||
<button type="button" @click="closePasswordModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors">{{ $t('cancel') }}</button>
|
||||
<button type="submit" :disabled="passwordLoading" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors disabled:opacity-50">
|
||||
{{ passwordLoading ? $t('saving') : $t('savePassword') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedWorkerIds.length > 0"
|
||||
class="fixed bottom-0 left-0 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg py-4 px-8 flex flex-col sm:flex-row justify-center items-center gap-4 sm:gap-6 z-40">
|
||||
<span class="font-medium text-gray-800 dark:text-white">{{ selectedWorkerIds.length }} worker(s) selected</span>
|
||||
<button @click="openBulkTagEditor"
|
||||
class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
|
||||
{{ $t('bulkEditTags') }}
|
||||
</button>
|
||||
<button @click="selectedWorkerIds = []"
|
||||
class="text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white px-4 py-2 rounded-md transition-colors duration-200 w-full sm:w-auto">
|
||||
{{ $t('clearSelection') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiFetch } from '@/api.js'
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { apiFetch } from '@/api.js';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
// --- STATE ---
|
||||
const router = useRouter()
|
||||
const workers = ref([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const newWorker = ref({ fullName: '', username: '', password: '' })
|
||||
const isManager = ref(false)
|
||||
const allTags = ref([])
|
||||
const newTagName = ref('')
|
||||
const editingWorker = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalWorkers = ref(0)
|
||||
const selectedTagIds = ref([])
|
||||
const isEditingTags = ref(false)
|
||||
const selectedWorkerIds = ref([])
|
||||
|
||||
// NEW: State for the password change modal
|
||||
const isPasswordModalVisible = ref(false)
|
||||
const editingWorkerPassword = ref(null)
|
||||
const newPassword = ref('')
|
||||
const confirmNewPassword = ref('')
|
||||
const passwordErrorMessage = ref('')
|
||||
const passwordSuccessMessage = ref('')
|
||||
const passwordLoading = ref(false)
|
||||
|
||||
// --- COMPUTED ---
|
||||
// ... existing computed properties ...
|
||||
const isFormValid = computed(
|
||||
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password,
|
||||
)
|
||||
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value))
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length
|
||||
})
|
||||
|
||||
const editorTitle = computed(() => {
|
||||
if (editingWorker.value) {
|
||||
return `Edit Tags for ${editingWorker.value.full_name}`
|
||||
}
|
||||
if (selectedWorkerIds.value.length > 0) {
|
||||
return `Editing Tags for ${selectedWorkerIds.value.length} Workers`
|
||||
}
|
||||
return 'Edit Tags'
|
||||
})
|
||||
|
||||
// --- WATCHERS ---
|
||||
// ... existing watchers ...
|
||||
let debounceTimer = null
|
||||
const debouncedFetch = () => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchWorkers(1)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
watch(searchQuery, debouncedFetch)
|
||||
watch(
|
||||
selectedTagIds,
|
||||
() => {
|
||||
fetchWorkers(1)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(currentPage, () => {
|
||||
selectedWorkerIds.value = []
|
||||
})
|
||||
|
||||
// --- METHODS ---
|
||||
// ... existing methods ...
|
||||
const fetchInitialData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [workersData, tagsData] = await Promise.all([
|
||||
apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=1&limit=${pageSize.value}`),
|
||||
apiFetch('/api/managers/tags'),
|
||||
])
|
||||
workers.value = workersData.workers
|
||||
totalWorkers.value = workersData.totalCount
|
||||
allTags.value = tagsData
|
||||
} catch (err) {
|
||||
errorMessage.value = $t('failedToLoadPageData')
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWorkers = async (page = currentPage.value) => {
|
||||
loading.value = true
|
||||
selectedWorkerIds.value = []
|
||||
try {
|
||||
const tagsQueryParam =
|
||||
selectedTagIds.value.length > 0 ? `&tags=${selectedTagIds.value.join(',')}` : ''
|
||||
const url = `/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}${tagsQueryParam}`
|
||||
const data = await apiFetch(url)
|
||||
workers.value = data.workers
|
||||
totalWorkers.value = data.totalCount
|
||||
currentPage.value = page
|
||||
} catch (err) {
|
||||
errorMessage.value = $t('failedToFetchWorkers')
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page > 0 && page <= totalPages.value) {
|
||||
fetchWorkers(page)
|
||||
}
|
||||
}
|
||||
|
||||
const addWorker = async () => {
|
||||
if (!isFormValid.value) return
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
...newWorker.value,
|
||||
role: isManager.value ? 'manager' : 'worker',
|
||||
}
|
||||
await apiFetch('/api/managers/workers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
await fetchWorkers(1)
|
||||
newWorker.value = { fullName: '', username: '', password: '' }
|
||||
isManager.value = false
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || $t('errorAddingUser')
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorker = async (id) => {
|
||||
if (!confirm($t('areYouSureDeleteWorker'))) return
|
||||
try {
|
||||
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' })
|
||||
if (workers.value.length === 1 && currentPage.value > 1) {
|
||||
await fetchWorkers(currentPage.value - 1)
|
||||
} else {
|
||||
await fetchWorkers(currentPage.value)
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = $t('failedToDeleteWorker')
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
const router = useRouter();
|
||||
|
||||
const viewRecords = (workerId) => {
|
||||
router.push(`/manager/attendance/${workerId}`)
|
||||
}
|
||||
router.push(`/manager/attendance/${workerId}`);
|
||||
};
|
||||
|
||||
const createTag = async () => {
|
||||
if (!newTagName.value) return
|
||||
// --- STATE ---
|
||||
const workers = ref([]);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const newWorker = ref({ fullName: '', username: '', password: '', department: '', position: '' });
|
||||
const isManager = ref(false);
|
||||
const searchQuery = ref('');
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalWorkers = ref(0);
|
||||
const selectedWorkerIds = ref([]);
|
||||
const isPasswordModalVisible = ref(false);
|
||||
const editingWorkerPassword = ref(null);
|
||||
const newPassword = ref('');
|
||||
const confirmNewPassword = ref('');
|
||||
const passwordErrorMessage = ref('');
|
||||
const passwordSuccessMessage = ref('');
|
||||
const passwordLoading = ref(false);
|
||||
const exportFilters = ref({ startDate: '', endDate: '' });
|
||||
const exportLoading = ref(false);
|
||||
|
||||
// --- COMPUTED ---
|
||||
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
|
||||
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value));
|
||||
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
|
||||
|
||||
// --- WATCHERS ---
|
||||
watch(searchQuery, () => fetchWorkers(1));
|
||||
watch(currentPage, () => selectedWorkerIds.value = []);
|
||||
|
||||
// --- METHODS ---
|
||||
const fetchWorkers = async (page = currentPage.value) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const newTag = await apiFetch('/api/managers/tags', {
|
||||
const data = await apiFetch(`/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
|
||||
workers.value = data.workers;
|
||||
totalWorkers.value = data.totalCount;
|
||||
currentPage.value = page;
|
||||
} catch (_err) {
|
||||
errorMessage.value = 'Failed to fetch workers.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const changePage = (page) => {
|
||||
if (page > 0 && page <= totalPages.value) fetchWorkers(page);
|
||||
};
|
||||
|
||||
const addWorker = async () => {
|
||||
if (!isFormValid.value) return;
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
await apiFetch('/api/managers/workers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tag_name: newTagName.value }),
|
||||
})
|
||||
allTags.value.push(newTag)
|
||||
newTagName.value = ''
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
body: JSON.stringify({ ...newWorker.value, role: isManager.value ? 'manager' : 'worker' }),
|
||||
});
|
||||
await fetchWorkers(1);
|
||||
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
|
||||
isManager.value = false;
|
||||
} catch (_err) {
|
||||
errorMessage.value = _err.message || 'Error adding user.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTag = async (tagId) => {
|
||||
if (!confirm($t('areYouSureDeleteTag'))) return
|
||||
return
|
||||
const deleteWorker = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this worker?')) return;
|
||||
try {
|
||||
await apiFetch(`/api/managers/tags/${tagId}`, { method: 'DELETE' })
|
||||
allTags.value = allTags.value.filter((tag) => tag.id !== tagId)
|
||||
// Also re-fetch workers to update their tag display if any had this tag
|
||||
fetchWorkers(currentPage.value)
|
||||
alert($t('tagDeleted'))
|
||||
} catch (err) {
|
||||
alert(err.message || $t('failedToDeleteTag'))
|
||||
console.error(err)
|
||||
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
|
||||
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
|
||||
} catch (_err) {
|
||||
errorMessage.value = 'Failed to delete worker.';
|
||||
}
|
||||
}
|
||||
|
||||
const openTagEditor = (worker) => {
|
||||
editingWorker.value = worker
|
||||
isEditingTags.value = true
|
||||
}
|
||||
|
||||
const openBulkTagEditor = () => {
|
||||
editingWorker.value = null
|
||||
isEditingTags.value = true
|
||||
}
|
||||
|
||||
const closeTagEditor = () => {
|
||||
isEditingTags.value = false
|
||||
editingWorker.value = null
|
||||
selectedWorkerIds.value = []
|
||||
fetchWorkers(currentPage.value)
|
||||
}
|
||||
|
||||
const isTagAppliedToSelection = (tagId) => {
|
||||
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
|
||||
if (!tagName) return false
|
||||
|
||||
const targetWorkers = editingWorker.value
|
||||
? [editingWorker.value]
|
||||
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
|
||||
if (targetWorkers.length === 0) return false
|
||||
|
||||
return targetWorkers.every((worker) => worker.tags && worker.tags.split(', ').includes(tagName))
|
||||
}
|
||||
|
||||
const toggleTagForSelection = async (tagId) => {
|
||||
const workersToUpdate = editingWorker.value
|
||||
? [editingWorker.value]
|
||||
: workers.value.filter((w) => selectedWorkerIds.value.includes(w.id))
|
||||
if (workersToUpdate.length === 0) return
|
||||
|
||||
const isAdding = !isTagAppliedToSelection(tagId)
|
||||
};
|
||||
|
||||
const clearDevice = async (workerId) => {
|
||||
if (!confirm('Are you sure you want to clear the registered device for this worker?')) return;
|
||||
try {
|
||||
for (const worker of workersToUpdate) {
|
||||
if (isAdding) {
|
||||
await apiFetch(`/api/managers/workers/${worker.id}/tags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tagId: tagId }),
|
||||
})
|
||||
} else {
|
||||
await apiFetch(`/api/managers/workers/${worker.id}/tags/${tagId}`, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
const tagName = allTags.value.find((t) => t.id === tagId)?.tag_name
|
||||
if (tagName) {
|
||||
workersToUpdate.forEach((worker) => {
|
||||
const tags = worker.tags ? worker.tags.split(', ').filter((t) => t) : []
|
||||
if (isAdding) {
|
||||
if (!tags.includes(tagName)) tags.push(tagName)
|
||||
} else {
|
||||
const index = tags.indexOf(tagName)
|
||||
if (index > -1) tags.splice(index, 1)
|
||||
}
|
||||
worker.tags = tags.join(', ')
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
alert($t('failedToUpdateTags'))
|
||||
console.error(err)
|
||||
await apiFetch(`/api/managers/workers/${workerId}/reset-device`, { method: 'PUT' });
|
||||
alert('Worker device cleared successfully.');
|
||||
} catch (_err) {
|
||||
alert(_err.message || 'Failed to clear device.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// NEW: Methods for password change modal
|
||||
const openPasswordModal = (worker) => {
|
||||
editingWorkerPassword.value = worker
|
||||
isPasswordModalVisible.value = true
|
||||
}
|
||||
editingWorkerPassword.value = worker;
|
||||
isPasswordModalVisible.value = true;
|
||||
};
|
||||
|
||||
const closePasswordModal = () => {
|
||||
isPasswordModalVisible.value = false
|
||||
editingWorkerPassword.value = null
|
||||
newPassword.value = ''
|
||||
confirmNewPassword.value = ''
|
||||
passwordErrorMessage.value = ''
|
||||
passwordSuccessMessage.value = ''
|
||||
passwordLoading.value = false
|
||||
}
|
||||
isPasswordModalVisible.value = false;
|
||||
editingWorkerPassword.value = null;
|
||||
newPassword.value = '';
|
||||
confirmNewPassword.value = '';
|
||||
passwordErrorMessage.value = '';
|
||||
passwordSuccessMessage.value = '';
|
||||
passwordLoading.value = false;
|
||||
};
|
||||
|
||||
const updateWorkerPassword = async () => {
|
||||
passwordErrorMessage.value = ''
|
||||
passwordSuccessMessage.value = ''
|
||||
|
||||
passwordErrorMessage.value = '';
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
passwordErrorMessage.value = 'Passwords do not match.'
|
||||
return
|
||||
passwordErrorMessage.value = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
passwordErrorMessage.value = 'Password must be at least 6 characters long.'
|
||||
return
|
||||
passwordErrorMessage.value = 'Password must be at least 6 characters long.';
|
||||
return;
|
||||
}
|
||||
if (!editingWorkerPassword.value) return
|
||||
|
||||
passwordLoading.value = true
|
||||
passwordLoading.value = true;
|
||||
try {
|
||||
await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ newPassword: newPassword.value }),
|
||||
})
|
||||
passwordSuccessMessage.value = 'Password updated successfully!'
|
||||
setTimeout(closePasswordModal, 2000) // Close modal after 2 seconds
|
||||
} catch (err) {
|
||||
passwordErrorMessage.value = err.message || 'Failed to update password.'
|
||||
console.error(err)
|
||||
});
|
||||
passwordSuccessMessage.value = 'Password updated successfully!';
|
||||
setTimeout(closePasswordModal, 2000);
|
||||
} catch (_err) {
|
||||
passwordErrorMessage.value = _err.message || 'Failed to update password.';
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
passwordLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isWorkerSelected = (workerId) => {
|
||||
return selectedWorkerIds.value.includes(workerId)
|
||||
}
|
||||
const isWorkerSelected = (workerId) => selectedWorkerIds.value.includes(workerId);
|
||||
|
||||
const toggleWorkerSelection = (workerId) => {
|
||||
const index = selectedWorkerIds.value.indexOf(workerId)
|
||||
if (index === -1) {
|
||||
selectedWorkerIds.value.push(workerId)
|
||||
} else {
|
||||
selectedWorkerIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
const index = selectedWorkerIds.value.indexOf(workerId);
|
||||
if (index === -1) selectedWorkerIds.value.push(workerId);
|
||||
else selectedWorkerIds.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const toggleSelectAll = (event) => {
|
||||
if (event.target.checked) {
|
||||
selectedWorkerIds.value = workers.value.map((w) => w.id)
|
||||
} else {
|
||||
selectedWorkerIds.value = []
|
||||
}
|
||||
}
|
||||
selectedWorkerIds.value = event.target.checked ? workers.value.map(w => w.id) : [];
|
||||
};
|
||||
|
||||
const toggleTagFilter = (tagId) => {
|
||||
const index = selectedTagIds.value.indexOf(tagId)
|
||||
if (index === -1) {
|
||||
selectedTagIds.value = [...selectedTagIds.value, tagId]
|
||||
} else {
|
||||
selectedTagIds.value = selectedTagIds.value.filter((id) => id !== tagId)
|
||||
}
|
||||
}
|
||||
const exportWorkHours = async () => {
|
||||
exportLoading.value = true;
|
||||
const { startDate, endDate } = exportFilters.value;
|
||||
let workerIds = selectedWorkerIds.value.join(',');
|
||||
|
||||
const clearTagFilter = () => {
|
||||
selectedTagIds.value = []
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export?startDate=${startDate}&endDate=${endDate}&workerIds=${workerIds}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Network response was not ok.');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `work_hours_${startDate}_to_${endDate}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (_err) {
|
||||
alert('Failed to export records.');
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchInitialData()
|
||||
})
|
||||
fetchWorkers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* No custom styles needed anymore, Tailwind handles everything */
|
||||
</style>
|
||||
|
||||
@@ -117,8 +117,8 @@ const fetchQrCodes = async () => {
|
||||
// CORRECT: Get the data directly from apiFetch
|
||||
const data = await apiFetch('/api/managers/qr-codes')
|
||||
qrCodes.value = data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch QR codes:', err)
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch QR codes:',_err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +144,8 @@ const addQrCode = async () => {
|
||||
if (error) console.error(error)
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Failed to add QR code:', err)
|
||||
} catch (_err) {
|
||||
console.error('Failed to add QR code:',_err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ const toggleQrStatus = async (qr) => {
|
||||
if (index !== -1) {
|
||||
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update QR status:', err)
|
||||
} catch (_err) {
|
||||
console.error('Failed to update QR status:',_err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,8 +178,8 @@ const deleteQrCode = async (id) => {
|
||||
})
|
||||
// Filter out the deleted QR code on success
|
||||
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete QR code:', err)
|
||||
} catch (_err) {
|
||||
console.error('Failed to delete QR code:',_err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 pb-20">
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('failedClockSummary') }}</h2>
|
||||
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div class="flex-grow">
|
||||
<label for="search-worker" class="sr-only">{{ $t('searchByNameOrDepartment') }}</label>
|
||||
<input type="text" id="search-worker" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<div class="flex flex-col">
|
||||
<label for="start-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('startDate') }}</label>
|
||||
<input type="date" id="start-date" v-model="filters.startDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="end-date" class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ $t('endDate') }}</label>
|
||||
<input type="date" id="end-date" v-model="filters.endDate" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
|
||||
</div>
|
||||
<button @click="fetchFailedRecords" :disabled="loadingReport" class="self-end bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
|
||||
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-[700px] w-full text-left">
|
||||
<thead class="bg-gray-100 dark:bg-gray-700">
|
||||
<tr class="border-b-2 border-gray-200 dark:border-gray-600">
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer" @click="sortBy('full_name')">
|
||||
{{ $t('worker') }}
|
||||
<span v-if="sortField === 'full_name'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider cursor-pointer text-center" @click="sortBy('count')">
|
||||
{{ $t('failedCount') }}
|
||||
<span v-if="sortField === 'count'" class="ml-1">{{ sortDirection === 'asc' ? '↑' : '↓' }}</span>
|
||||
</th>
|
||||
<th class="px-4 py-3 text-sm font-semibold text-gray-600 dark:text-gray-300 uppercase tracking-wider text-center">{{ $t('actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="record in sortedFailedRecords" :key="record.worker_id" class="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors duration-150">
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white font-medium">{{ record.full_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-800 dark:text-white text-center">{{ record.count }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button @click="showDetails(record.worker_id, record.full_name)" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ $t('viewDetails') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="failedRecords.length === 0 && !loadingReport">
|
||||
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400 italic">
|
||||
{{ $t('noRecordsFound') }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="loadingReport">
|
||||
<td colspan="3" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div class="flex justify-center items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ $t('loadingReport') }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div v-if="showDetailModal" class="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div class="flex justify-between items-center mb-4 border-b pb-3">
|
||||
<h3 class="text-xl font-semibold text-gray-800 dark:text-white">{{ detailModalTitle }}</h3>
|
||||
<button @click="showDetailModal = false" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="details-content overflow-y-auto flex-grow">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('timestamp') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('eventType') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('location') }}</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">{{ $t('notes') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="detail in detailRecords" :key="detail.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ new Date(detail.timestamp).toLocaleString() }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
{{ $t(detail.event_type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.qrCodeUsedName || $t('nA') }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800 dark:text-gray-200">{{ detail.notes || $t('nA') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { apiFetch } from '@/api.js';
|
||||
|
||||
const { t: $t } = useI18n();
|
||||
|
||||
// --- STATE ---
|
||||
const searchQuery = ref('');
|
||||
const filters = ref({ startDate: '', endDate: '' });
|
||||
const loadingReport = ref(false);
|
||||
const failedRecords = ref([]);
|
||||
const showDetailModal = ref(false);
|
||||
const detailRecords = ref([]);
|
||||
const detailModalTitle = ref('');
|
||||
const sortField = ref('count');
|
||||
const sortDirection = ref('desc');
|
||||
|
||||
// --- COMPUTED ---
|
||||
const sortedFailedRecords = computed(() => {
|
||||
return [...failedRecords.value].sort((a, b) => {
|
||||
const modifier = sortDirection.value === 'asc' ? 1 : -1;
|
||||
if (a[sortField.value] < b[sortField.value]) return -1 * modifier;
|
||||
if (a[sortField.value] > b[sortField.value]) return 1 * modifier;
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
// --- METHODS ---
|
||||
const fetchInitialData = async () => {
|
||||
const today = new Date();
|
||||
filters.value.endDate = today.toISOString().split('T')[0];
|
||||
const twoMonthsAgo = new Date();
|
||||
twoMonthsAgo.setMonth(today.getMonth() - 2);
|
||||
filters.value.startDate = twoMonthsAgo.toISOString().split('T')[0];
|
||||
await fetchFailedRecords();
|
||||
};
|
||||
|
||||
const fetchFailedRecords = async () => {
|
||||
loadingReport.value = true;
|
||||
failedRecords.value = [];
|
||||
|
||||
try {
|
||||
const url = `/api/managers/failed-records?search=${searchQuery.value}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
||||
failedRecords.value = await apiFetch(url);
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch failed records',_err);
|
||||
alert('Failed to fetch records.');
|
||||
} finally {
|
||||
loadingReport.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sortBy = (field) => {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField.value = field;
|
||||
sortDirection.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const showDetails = async (workerId, workerName) => {
|
||||
try {
|
||||
detailModalTitle.value = `${$t('failedRecordsFor')} ${workerName}`;
|
||||
const url = `/api/managers/failed-records/details?workerId=${workerId}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`;
|
||||
detailRecords.value = await apiFetch(url);
|
||||
showDetailModal.value = true;
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch details',_err);
|
||||
alert('Failed to load details.');
|
||||
}
|
||||
};
|
||||
|
||||
watch(searchQuery, fetchFailedRecords);
|
||||
|
||||
onMounted(() => {
|
||||
fetchInitialData();
|
||||
});
|
||||
</script>
|
||||
+58
-5
@@ -10,6 +10,7 @@
|
||||
"invalidToken": "Invalid token received from server.",
|
||||
"english": "English",
|
||||
"malay": "Malay",
|
||||
"setting": "Setting",
|
||||
|
||||
"yourStatus": "Your Status",
|
||||
"clockedIn": "Clocked In",
|
||||
@@ -24,19 +25,20 @@
|
||||
"out": "Out",
|
||||
"cancel": "Cancel",
|
||||
|
||||
"clockHistory": "Clock History",
|
||||
"viewMyClockHistory": "View My Clock History",
|
||||
"changeMyPassword": "Change My Password",
|
||||
"updateYourPassword": "Update Your Password",
|
||||
"myClockHistory": "My Clock History",
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"noClockHistory": "You have no clocking history.",
|
||||
"clockHistoryFetchFail": "Failed to fetch clock history:",
|
||||
"viewClockHistory": "View My Clock History →",
|
||||
"changePassword": "Change My Password →",
|
||||
"viewClockHistory": "View My Clock History",
|
||||
"changePassword": "Change My Password",
|
||||
|
||||
"successClockIn": "Successfully clocked in.",
|
||||
"successClockOut": "Successfully clocked out.",
|
||||
"qrFail": "Could not detect a QR code. Please try again.",
|
||||
"geoFail": "Unable to retrieve your location: {message}. Please enable location services.",
|
||||
"successClock": "Successfully clocked at {location}.",
|
||||
"changePasswordTitle": "Change Password",
|
||||
"currentPassword": "Current Password",
|
||||
@@ -46,6 +48,14 @@
|
||||
|
||||
"tabPersonnel": "Personnel",
|
||||
"tabAttendance": "Attendance",
|
||||
"tabWarning": "Warnings",
|
||||
"warningSettings": "Warning Settings",
|
||||
"failedClockSummary": "Failed Clock Summary",
|
||||
"failedCount": "Failed Count",
|
||||
"viewDetails": "View Details",
|
||||
"fetchRecords": "Fetch Records",
|
||||
"failedRecordsFor": "Failed Records for ",
|
||||
"eventType": "Event Type",
|
||||
"tabQrCodes": "QR Codes",
|
||||
"uploadQrImage": "Upload QR Image",
|
||||
|
||||
@@ -58,7 +68,7 @@
|
||||
"tryAgain": "Try Again",
|
||||
"qrDetectedGettingLocation": "QR Code detected. Getting location...",
|
||||
"geolocationNotSupported": "Geolocation is not supported by your browser.",
|
||||
"unableToRetrieveLocation": "Unable to retrieve your location: {message}. Please enable location services.",
|
||||
"unableToRetrieveLocation": "Unable to retrieve your location. Please check location permissions. (Details: {message})",
|
||||
"qrNotDetectedTryAgain": "Could not detect a QR code. Please try again.",
|
||||
"updatePassword": "Update Password",
|
||||
"passwordsNoMatch": "New passwords do not match.",
|
||||
@@ -118,9 +128,13 @@
|
||||
"tagLoadError": "Could not load workers for the selected tag.",
|
||||
"generateReportError": "Please select workers, set valid date range, and enter a salary.",
|
||||
"reportGenerationError": "An error occurred while generating the report.",
|
||||
"exportAll": "Export All",
|
||||
"export": "Export",
|
||||
|
||||
"addNewUser": "Add New User",
|
||||
"fullName": "Full Name",
|
||||
"department": "Department",
|
||||
"position": "Position",
|
||||
"egJohnSmith": "e.g. John Smith",
|
||||
"egJsmith": "e.g. jsmith",
|
||||
"eg123456": "e.g. 123456",
|
||||
@@ -130,16 +144,20 @@
|
||||
"manageTags": "Manage Tags",
|
||||
"createNewTag": "Create New Tag",
|
||||
"egTeam": "e.g. Team",
|
||||
"egSales": "e.g. Sales",
|
||||
"egManager": "e.g. Manager",
|
||||
"createTag": "Create Tag",
|
||||
"tags": "Tags",
|
||||
"workerRoster": "Worker Roster",
|
||||
"searchByNameOrUsername": "Search by name or username",
|
||||
"searchByNameOrDepartment": "Search by name or department",
|
||||
"filterByTag": "Filter by tag",
|
||||
"clearFilter": "Clear filter",
|
||||
"dateJoined": "Date Joined",
|
||||
"actions": "Actions",
|
||||
"editTags": "Edit Tags",
|
||||
"viewRecords": "View Records",
|
||||
"clearDevice": "Clear Device",
|
||||
"delete": "Delete",
|
||||
"loadingWorkers": "Loading workers...",
|
||||
"noWorkersFound": "No workers found.",
|
||||
@@ -180,5 +198,40 @@
|
||||
"download": "Download",
|
||||
"noQrCodesFound": "No QR codes found. Create one above!",
|
||||
"deleteQrConfirm": "Are you sure you want to delete this QR code? This cannot be undone.",
|
||||
"qrDownloadError": "Sorry, the QR code could not be downloaded."
|
||||
"qrDownloadError": "Sorry, the QR code could not be downloaded.",
|
||||
"loading": "Loading...",
|
||||
|
||||
"tabGeofencing": "Geofencing",
|
||||
"createGeofence": "Create Geofence",
|
||||
"drawInstruction": "Click the polygon tool on the map to start drawing a new geofence. Click the first point to finish.",
|
||||
"geofenceName": "Geofence Name",
|
||||
"geofenceNamePlaceholder": "e.g., Main Warehouse Zone",
|
||||
"saveGeofence": "Save Geofence",
|
||||
"existingGeofences": "Existing Geofences",
|
||||
"view": "View",
|
||||
"noGeofencesFound": "No Geofences Found",
|
||||
"startOver" : "Start Over",
|
||||
|
||||
"workScheduleTitle": "Work Schedule",
|
||||
"workScheduleDescription": "Click on a date to toggle its status. Enabled days are green. Changes will not be saved until you click 'Apply Changes'.",
|
||||
"pendingChanges": "Pending Changes",
|
||||
"noPendingChanges": "Click on the calendar to enable or disable dates.",
|
||||
"datesToEnable": "Enable these dates:",
|
||||
"datesToDisable": "Disable these dates:",
|
||||
"applyChanges": "Apply Changes",
|
||||
"discardChanges": "Discard Changes",
|
||||
|
||||
"statusClockedIn": "You are Clocked In",
|
||||
"statusClockedOut": "You are Clocked Out",
|
||||
"scanToClockIn": "Scan QR Code to Clock In",
|
||||
"scanToClockOut": "Scan QR Code to Clock Out",
|
||||
|
||||
"error.default": "An unexpected error occurred. Please try again.",
|
||||
"error.clockingDisabled": "Clocking is disabled for today. Your attempt has been logged.",
|
||||
"error.noActiveGeofence": "Clocking failed: No active work area is defined on the server.",
|
||||
"error.outsideGeofence": "Clocking failed: You are outside the designated work area by {distance}m.",
|
||||
"error.invalidQrCode": "Clocking failed: The scanned QR Code is invalid or no longer active.",
|
||||
"error.alreadyClockedIn": "Action failed: You are already clocked in.",
|
||||
"error.alreadyClockedOut": "Action failed: You are already clocked out.",
|
||||
"error.criticalServer": "A critical server error occurred. Please contact support."
|
||||
}
|
||||
+59
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"appTitle": "Sistem Masuk/Keluar Kerja",
|
||||
"appTitle": "Sistem Kehadiran",
|
||||
"logout": "Log Keluar",
|
||||
"login": "Log Masuk",
|
||||
"username": "Nama Pengguna",
|
||||
@@ -10,6 +10,7 @@
|
||||
"invalidToken": "Token tidak sah diterima dari pelayan.",
|
||||
"english": "Bahasa Inggeris",
|
||||
"malay": "Bahasa Melayu",
|
||||
"setting": "Tetapan",
|
||||
|
||||
"yourStatus": "Status Anda",
|
||||
"clockedIn": "Sudah Masuk",
|
||||
@@ -24,14 +25,16 @@
|
||||
"out": "Keluar",
|
||||
"cancel": "Batal",
|
||||
|
||||
"clockHistroy": "Sejarah Kehadiran",
|
||||
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||
"changeMyPassword": "Tukar Kata Laluan Saya",
|
||||
"updateYourPassword": "Tukar Kata Laluan Anda",
|
||||
"myClockHistory": "Sejarah Kehadiran Saya",
|
||||
"backToDashboard": "Kembali ke Papan Pemuka",
|
||||
"noClockHistory": "Tiada rekod kehadiran.",
|
||||
"clockHistoryFetchFail": "Gagal untuk dapatkan sejarah kehadiran:",
|
||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya →",
|
||||
"changePassword": "Tukar Kata Laluan Saya →",
|
||||
"viewClockHistory": "Lihat Sejarah Kehadiran Saya",
|
||||
"changePassword": "Tukar Kata Laluan Saya",
|
||||
|
||||
"successClockIn": "Berjaya masuk kerja.",
|
||||
"successClockOut": "Berjaya keluar kerja.",
|
||||
@@ -46,6 +49,14 @@
|
||||
|
||||
"tabPersonnel": "Personel",
|
||||
"tabAttendance": "Kehadiran",
|
||||
"tabWarning": "Amaran",
|
||||
"warningSettings": "Tetapan Amaran",
|
||||
"failedClockSummary": "Ringkasan Kegagalan Clock",
|
||||
"failedCount": "Bilangan Gagal",
|
||||
"viewDetails": "Lihat Butiran",
|
||||
"fetchRecords": "Dapatkan Rekod",
|
||||
"failedRecordsFor": "Rekod Gagal untuk ",
|
||||
"eventType": "Jenis Peristiwa",
|
||||
"tabQrCodes": "Kod QR",
|
||||
"uploadQrImage": "Muat Naik Imej QR",
|
||||
|
||||
@@ -58,7 +69,7 @@
|
||||
"tryAgain": "Cuba Lagi",
|
||||
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
|
||||
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
|
||||
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
|
||||
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})",
|
||||
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
|
||||
"updatePassword": "Kemaskini Kata Laluan",
|
||||
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
|
||||
@@ -118,8 +129,12 @@
|
||||
"tagLoadError": "Tidak dapat memuatkan pekerja untuk tag yang dipilih.",
|
||||
"generateReportError": "Sila pilih pekerja, tetapkan tarikh, dan masukkan gaji.",
|
||||
"reportGenerationError": "Ralat semasa menjana laporan.",
|
||||
"exportAll": "Eksport Semua",
|
||||
"export": "Eksport",
|
||||
"addNewUser": "Tambah Pengguna Baharu",
|
||||
"fullName": "Nama Penuh",
|
||||
"department": "Jabatan",
|
||||
"position": "Jawatan",
|
||||
"egJohnSmith": "cth. John Smith",
|
||||
"egJsmith": "cth. jsmith",
|
||||
"eg123456": "cth. 123456",
|
||||
@@ -129,16 +144,20 @@
|
||||
"manageTags": "Urus Tag",
|
||||
"createNewTag": "Cipta Tag Baharu",
|
||||
"egTeam": "cth. Pasukan",
|
||||
"egSales": "cth. Jualan",
|
||||
"egManager": "cth. Pengurus",
|
||||
"createTag": "Cipta Tag",
|
||||
"tags": "Tag",
|
||||
"workerRoster": "Senarai Pekerja",
|
||||
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
|
||||
"searchByNameOrDepartment": "Cari mengikut nama atau jabatan",
|
||||
"filterByTag": "Tapis mengikut tag",
|
||||
"clearFilter": "Padam tapisan",
|
||||
"dateJoined": "Tarikh Sertai",
|
||||
"actions": "Tindakan",
|
||||
"editTags": "Sunting Tag",
|
||||
"viewRecords": "Lihat Rekod",
|
||||
"clearDevice": "Padam Peranti",
|
||||
"delete": "Padam",
|
||||
"loadingWorkers": "Memuatkan pekerja...",
|
||||
"noWorkersFound": "Tiada pekerja dijumpai.",
|
||||
@@ -179,5 +198,40 @@
|
||||
"download": "Muat Turun",
|
||||
"noQrCodesFound": "Tiada kod QR dijumpai. Sila cipta di atas!",
|
||||
"deleteQrConfirm": "Adakah anda pasti ingin memadam kod QR ini? Tindakan ini tidak boleh diundur.",
|
||||
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun."
|
||||
"qrDownloadError": "Maaf, kod QR tidak dapat dimuat turun.",
|
||||
"loading": "Memuatkan...",
|
||||
|
||||
"tabGeofencing": "Geofencing",
|
||||
"createGeofence": "Cipta Geofence",
|
||||
"drawInstruction": "Klik alat poligon di peta untuk mula menggambar geofence baru. Klik titik pertama untuk selesai.",
|
||||
"geofenceName": "Nama Geofence",
|
||||
"geofenceNamePlaceholder": "cth., Zon Gudang Utama",
|
||||
"saveGeofence": "Simpan Geofence",
|
||||
"existingGeofences": "Geofences Sedia Ada",
|
||||
"view": "Lihat",
|
||||
"noGeofencesFound": "Tiada Geofences Dijumpai",
|
||||
"startOver" : "Mula Semula",
|
||||
|
||||
"workScheduleTitle": "Jadual Kerja",
|
||||
"workScheduleDescription": "Klik pada tarikh untuk menukar statusnya. Hari yang didayakan berwarna hijau. Perubahan tidak akan disimpan sehingga anda mengklik 'Guna Perubahan'.",
|
||||
"pendingChanges": "Perubahan Belum Selesai",
|
||||
"noPendingChanges": "Klik pada kalendar untuk mendayakan atau menyahdayakan tarikh.",
|
||||
"datesToEnable": "Dayakan tarikh ini:",
|
||||
"datesToDisable": "Nyahdayakan tarikh ini:",
|
||||
"applyChanges": "Guna Perubahan",
|
||||
"discardChanges": "Buang Perubahan",
|
||||
|
||||
"statusClockedIn": "Anda Sudah Masuk Kerja",
|
||||
"statusClockedOut": "Anda Sudah Keluar Kerja",
|
||||
"scanToClockIn": "Imbas Kod QR untuk Masuk Kerja",
|
||||
"scanToClockOut": "Imbas Kod QR untuk Keluar Kerja",
|
||||
|
||||
"error.default": "Ralat tidak dijangka telah berlaku. Sila cuba lagi.",
|
||||
"error.clockingDisabled": "Fungsi masuk/keluar kerja dilumpuhkan untuk hari ini. Percubaan anda telah direkodkan.",
|
||||
"error.noActiveGeofence": "Gagal masuk/keluar: Tiada kawasan kerja aktif yang ditetapkan pada pelayan.",
|
||||
"error.outsideGeofence": "Gagal masuk/keluar: Anda berada di luar kawasan kerja yang ditetapkan sejauh {distance}m.",
|
||||
"error.invalidQrCode": "Gagal masuk/keluar: Kod QR yang diimbas tidak sah atau tidak lagi aktif.",
|
||||
"error.alreadyClockedIn": "Tindakan gagal: Anda sudah masuk kerja.",
|
||||
"error.alreadyClockedOut": "Tindakan gagal: Anda sudah keluar kerja.",
|
||||
"error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan."
|
||||
}
|
||||
+31
-28
@@ -1,33 +1,33 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import WorkerDashboardView from '../views/WorkerDashboardView.vue'
|
||||
// import WorkerDashboardView from '../views/WorkerDashboardView.vue'
|
||||
import ManagerDashboardView from '../views/ManagerDashboardView.vue'
|
||||
import WorkerHistoryView from '../views/WorkerHistoryView.vue'
|
||||
// import WorkerHistoryView from '../views/WorkerHistoryView.vue'
|
||||
import AttendanceRecordView from '../views/AttendanceRecordView.vue'
|
||||
import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
// import ChangePasswordView from '../views/ChangePasswordView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'login', component: LoginView },
|
||||
{
|
||||
path: '/worker/dashboard',
|
||||
name: 'worker-dashboard',
|
||||
component: WorkerDashboardView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/history',
|
||||
name: 'worker-history',
|
||||
component: WorkerHistoryView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
{
|
||||
path: '/worker/change-password',
|
||||
name: 'worker-change-password',
|
||||
component: ChangePasswordView,
|
||||
meta: { requiresAuth: true, role: 'worker' },
|
||||
},
|
||||
// {
|
||||
// path: '/worker/dashboard',
|
||||
// name: 'worker-dashboard',
|
||||
// component: WorkerDashboardView,
|
||||
// meta: { requiresAuth: true, role: 'worker' },
|
||||
// },
|
||||
// {
|
||||
// path: '/worker/history',
|
||||
// name: 'worker-history',
|
||||
// component: WorkerHistoryView,
|
||||
// meta: { requiresAuth: true, role: 'worker' },
|
||||
// },
|
||||
// {
|
||||
// path: '/worker/change-password',
|
||||
// name: 'worker-change-password',
|
||||
// component: ChangePasswordView,
|
||||
// meta: { requiresAuth: true, role: 'worker' },
|
||||
// },
|
||||
{
|
||||
path: '/manager/dashboard',
|
||||
name: 'manager-dashboard',
|
||||
@@ -51,20 +51,23 @@ router.beforeEach((to, from, next) => {
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (isLoggedIn) {
|
||||
// Check if user has the required role
|
||||
if (to.meta.role && to.meta.role === userRole) {
|
||||
next() // User is logged in and has the correct role
|
||||
// Since worker login is disabled, we only check for manager role
|
||||
if (userRole === 'manager') {
|
||||
next()
|
||||
} else {
|
||||
// User is logged in but trying to access a page for another role
|
||||
// Redirect them to their own dashboard
|
||||
next(userRole === 'worker' ? '/worker/dashboard' : '/manager/dashboard')
|
||||
// If a non-manager is somehow logged in, or role is missing, redirect to login
|
||||
sessionStorage.clear() // Clear session for safety
|
||||
next('/')
|
||||
}
|
||||
} else {
|
||||
// User is not logged in, redirect to login page
|
||||
next('/')
|
||||
}
|
||||
} else if (to.name === 'login' && isLoggedIn && userRole === 'manager') {
|
||||
// If a logged-in manager tries to visit the login page, redirect to their dashboard
|
||||
next('/manager/dashboard')
|
||||
} else {
|
||||
// For public routes like the login page
|
||||
// For public routes
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -54,6 +54,10 @@
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0">
|
||||
{{ $t('filterRecords') }}
|
||||
</button>
|
||||
<button @click="exportRawRecords" :disabled="exportLoading"
|
||||
class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md w-full sm:w-auto flex-shrink-0 disabled:opacity-50">
|
||||
{{ exportLoading ? $t('exporting') : $t('export') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
@@ -150,6 +154,8 @@ const filters = ref({
|
||||
endDate: today.toISOString().split('T')[0],
|
||||
})
|
||||
|
||||
const exportLoading = ref(false);
|
||||
|
||||
const fetchRecords = async () => {
|
||||
let url = `/api/managers/attendance-records?workerIds=${workerId}`
|
||||
if (filters.value.startDate && filters.value.endDate) {
|
||||
@@ -167,9 +173,9 @@ const fetchRecords = async () => {
|
||||
} else {
|
||||
records.value = []
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch attendance records:', err)
|
||||
alert(err.message)
|
||||
} catch (_err) {
|
||||
console.error('Failed to fetch attendance records:',_err)
|
||||
alert(_err.message)
|
||||
records.value = []
|
||||
}
|
||||
}
|
||||
@@ -202,12 +208,39 @@ const addManualClockOut = async () => {
|
||||
manualClockOut.value.notes = ''
|
||||
manualClockOut.value.timestamp = toLocalISOString(new Date())
|
||||
fetchRecords()
|
||||
} catch (err) {
|
||||
console.error('Failed to submit manual clock-out:', err)
|
||||
alert(t('manualClockOutError', { msg: err.message }))
|
||||
} catch (_err) {
|
||||
console.error('Failed to submit manual clock-out:',_err)
|
||||
alert(t('manualClockOutError', { msg: _err.message }))
|
||||
}
|
||||
}
|
||||
|
||||
const exportRawRecords = async () => {
|
||||
exportLoading.value = true;
|
||||
const { startDate, endDate } = filters.value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/managers/attendance-records/export-raw?startDate=${startDate}&endDate=${endDate}&workerIds=${workerId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${sessionStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Network response was not ok.');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `raw_attendance_${workerName.value}_${startDate}_to_${endDate}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (_err) {
|
||||
alert('Failed to export records.');
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
|
||||
@@ -47,10 +47,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { apiFetch } from '@/api.js'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const passwords = ref({
|
||||
currentPassword: '',
|
||||
@@ -85,8 +83,8 @@ const handleChangePassword = async () => {
|
||||
})
|
||||
successMessage.value = 'passwordUpdated'
|
||||
passwords.value = { currentPassword: '', newPassword: '', confirmPassword: '' }
|
||||
} catch (err) {
|
||||
errorMessage.value = err.message || 'passwordUpdateError'
|
||||
} catch (_err) {
|
||||
errorMessage.value = _err.message || 'passwordUpdateError'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||
{{ $t('tabPersonnel') }}
|
||||
</button>
|
||||
<button @click="activeTab = 'attendance'" :class="{
|
||||
<button @click="activeTab = 'warning'" :class="{
|
||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||
activeTab === 'attendance',
|
||||
activeTab === 'warning',
|
||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||
activeTab !== 'attendance',
|
||||
activeTab !== 'warning',
|
||||
}"
|
||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||
{{ $t('tabAttendance') }}
|
||||
{{ $t('tabWarning') }}
|
||||
</button>
|
||||
<button @click="activeTab = 'qr'" :class="{
|
||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||
@@ -29,23 +29,43 @@
|
||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||
{{ $t('tabQrCodes') }}
|
||||
</button>
|
||||
</div>
|
||||
<button @click="activeTab = 'geofencing'" :class="{
|
||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||
activeTab === 'geofencing',
|
||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||
activeTab !== 'geofencing',
|
||||
}"
|
||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||
{{ $t('tabGeofencing') }}
|
||||
</button>
|
||||
<button @click="activeTab = 'killSwitch'" :class="{
|
||||
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
|
||||
activeTab === 'killSwitch',
|
||||
'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400 hover:border-blue-300 dark:hover:border-blue-600':
|
||||
activeTab !== 'killSwitch',
|
||||
}"
|
||||
class="flex-shrink-0 px-3 py-2 text-sm sm:px-4 sm:py-2 sm:text-base transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 whitespace-nowrap">
|
||||
{{ $t('workScheduleTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<AttendanceReporting v-if="activeTab === 'attendance'" />
|
||||
<WarningReporting v-if="activeTab === 'warning'" />
|
||||
<QrCodeManagement v-if="activeTab === 'qr'" />
|
||||
<PersonnelManagement v-if="activeTab === 'personnel'" />
|
||||
<GeofenceManagement v-if="activeTab === 'geofencing'" />
|
||||
<KillSwitchManagement v-if="activeTab === 'killSwitch'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import AttendanceReporting from '@/components/AttendanceReporting.vue'
|
||||
import WarningReporting from '@/components/WarningReporting.vue'
|
||||
import QrCodeManagement from '@/components/QrCodeManagement.vue'
|
||||
import PersonnelManagement from '@/components/PersonnelManagement.vue'
|
||||
import GeofenceManagement from '@/components/GeofenceManagement.vue'
|
||||
import KillSwitchManagement from '@/components/KillSwitchManagement.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref('personnel')
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user