feat: Add manager permissions management component and related functionality

- Implemented ManagerPermissions.vue for managing manager accounts, including adding, editing, and deleting managers.
- Integrated a modal for adding new managers with form validation.
- Added functionality to fetch, display, and paginate manager data.
- Created a toast notification system for user feedback on actions.
- Developed a reusable Toast component for displaying notifications.
- Introduced a useToast composable for managing toast notifications.
- Added permissions management for managers, including fetching and saving permissions.
- Implemented password change functionality for managers.
- Enhanced error handling and user feedback throughout the manager management process.
- Added root CA and private key files for secure communication.
This commit is contained in:
sudomarcma
2025-07-18 15:56:55 +08:00
parent 601b32a7c8
commit 7769a89708
25 changed files with 1708 additions and 467 deletions
+26
View File
@@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEajCCAtKgAwIBAgIQCidY0lKaDwojBgr6MpeBzzANBgkqhkiG9w0BAQsFADCB
kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM
MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh
bikwHhcNMjUwNzA0MDc0NjExWhcNMjcxMDA0MDc0NjExWjBeMScwJQYDVQQKEx5t
a2NlcnQgZGV2ZWxvcG1lbnQgY2VydGlmaWNhdGUxMzAxBgNVBAsMKk1BSUxcbWFz
b25neWFuQERFU0tUT1AtSVFVOERERCAobWFzb25neWFuKTCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBANl8SofEGCDGYv2J22Qanu6LgxvvKd9wKB1Lf2x6
eBD84tHmVZXKuQElo9ZkEbljKA9M8dNCTrxNFzGL6dB2b3fRHBnEYhiANKnMohgb
oul+Tiq2/Pye4SHWglvsM6DboImARRW58L8FyA3mnS9VgS7TUb3W2tRQhLHU1s/R
QjZulIQvpe+k0dW+S1zd7wBg790K5GNs9va/8KEM1v3esBNOpCbKeWzeRT/Si9ZA
Dfm72SSWslHQEXtuz8AQVtfk0qJMUB0URmyadir0aJwuDC6m5iQSKtLTvQp+n0/Z
lundQQbsnm71FnCAD9PSz+IaB3euEOwUGbGnDW9+10kGTekCAwEAAaNwMG4wDgYD
VR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFElR
m5C15845O14vXvSvwjxwtiJEMCYGA1UdEQQfMB2CCWxvY2FsaG9zdIcECgACAocE
fwAAAYcEwKgkNjANBgkqhkiG9w0BAQsFAAOCAYEAsOdvadeTxsAT0Le63PPEYPiZ
drkEJdTyu9Thv9nFhLCD4vUYIZrlE3brFXD1iVTR1muJsalfnmW9azIwGBHw52bZ
B2XdA6HNZEklSRtqNMEAGJsdnbGuCTPa1lLNuzCQodSnmbvu6Y5K13Pq/asl3DVW
h/hczwX5NrQvlvyDwI0kVSDRmEb5AYnEic5h64gEyILTVWopT8RzA+B8AtW3oP3d
pfoCErwQvxfkNd3UGWk+rDlQWwApzh+N4P+3vAjhAra7Yoj+JtT0SnXeAjXhbB0E
WmDcMNQwxUg1FN5ATR5pAMoSSNviLaf/jYb93naZ6YZKgSfSIKNgUJz+ppgHNBFr
326JOYH0yzyhWXUXchzsn1ytMkhddNVZhRbGceOkyZEkaSynZR4om8ZGxPJYfCBB
m9sH27eCeJBy9DXk0ZUkJg+y3C+jizenHiPnED92Z1EZ0ke7fNufiVZs0yQl2uxg
V5mgoQSLxu4LHXQnTm/NQugY9S8rfbz510WutGKi
-----END CERTIFICATE-----
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDZfEqHxBggxmL9
idtkGp7ui4Mb7ynfcCgdS39sengQ/OLR5lWVyrkBJaPWZBG5YygPTPHTQk68TRcx
i+nQdm930RwZxGIYgDSpzKIYG6Lpfk4qtvz8nuEh1oJb7DOg26CJgEUVufC/BcgN
5p0vVYEu01G91trUUISx1NbP0UI2bpSEL6XvpNHVvktc3e8AYO/dCuRjbPb2v/Ch
DNb93rATTqQmynls3kU/0ovWQA35u9kklrJR0BF7bs/AEFbX5NKiTFAdFEZsmnYq
9GicLgwupuYkEirS070Kfp9P2Zbp3UEG7J5u9RZwgA/T0s/iGgd3rhDsFBmxpw1v
ftdJBk3pAgMBAAECggEAIeDztzx7ybc9umMcMvbWpTBEZziVXEIbbZzSJ7LYO0U5
jBsGYAQpV51mbUI/ZJKmreN9lDwzCbA0mbpC3P9mE9MWPolSAqEOExlWcszzTs4n
HQ5OUIfraBsDSZB85mTwGBtMJ7tEXm1nIYs4FySJsCKpDBqJEiPM1+rg35SobNP5
aOvuLgXe3V6wVuihakoGj8nUtCgKsPr/14ybcF6Fcv5ULI6Tls0G8HOY92Kesb/o
NZL1YmMVevY+RKYzrZKca6mRanMIjnjrnYGX5V404mh6GQKpGdgrcMEONMbJje2H
44MjyJYhQ67/ItOKOuC1JG1LuRq/5SXTAS2WW7g+1QKBgQDiWefUn2v3pYd4CIFd
Bz43TpHuQZiqX5UOvPFOrk5LT+EhYHTpSCThrc5piqk+XsnV3G1dyDnbBK8k4FPa
yyrUuNOSvQlspSr0u++5i7cRLwq7C6kRtTzW8nr6Az8bE6u1prvXKFIWKP/doWeg
U7jPMCVKN+oxvNN6Fi0meecLxwKBgQD1+RkfrUCg7xpr+gn2R2LxryL2u/oxVRmo
4TZqBQoXcQJBx+UrTcIL8XENohYYI/7HCZfD/cBxpFGNqclD3DjzjH2NZ43MBlbN
up3wD+Ks2LVOilyOrxK3be/cnvPyQJantd/NBnHOTsQoBUPdhbrqdyrjYW0o4WZQ
5c36f934zwKBgQCRiTEQeviWoG279ewHfpK4SOJ3iOG6Gf7jHQUii9x3fALKzRQe
sm5UVMZ1AdzT52prAXGobQcWFarvUPVZpmwBnl0a6kTXAFPgS75VVMn+WHrTzSmF
4zwdEIeVnOTEah9riqsYKiqtaOsq+45/fZVEUjaHw+/mzvxCcWPSa2rtHQKBgEUe
amDsXmzaw6Hz8TizdqpTfI+44uVZ9IvwPUotgFh1+Rxi/5LbltukTRB3q528/6sO
lwcMFzfX5NLaEyRujdJieCV0I/RhE6Nb/WWoERphCxG276topunEitKEGCjK3Yrj
ILCMTw6aM6TLVfa5zXx1YCflCLekHww8h1UM+WMhAoGAH6U1XzkW3ozty7sQ5vxZ
jzri0xUpp06EA/EtfhkCRPgaYCkL5aXan+jNAZPfTG6mGudULWjTIfEEQrMJ54CN
sItMoPP2S4EDuj4xdQWe8eTeMqtGG/lAmG2Yr9QajWofNLwaBtsXANYCDGadNUxa
2pog6+BDaFEC64IwkoBYgZ8=
-----END PRIVATE KEY-----
+369 -37
View File
@@ -16,17 +16,39 @@ export default function(db) {
if (err || user.role !== 'manager') {
return res.status(403).json({ message: 'Forbidden' });
}
req.user = user;
req.user = { ...user, id: user.userId }; // Correctly map userId to id
next();
});
} else {
res.status(401).json({ message: 'Unauthorized' });
}
};
// Middleware to check for specific permissions
const checkPermission = (requiredPermission) => {
return async (req, res, next) => {
try {
const managerId = req.user.id;
const [rows] = await db.execute(
'SELECT * FROM manager_permissions WHERE manager_id = ?',
[managerId]
);
if (rows.length === 0 || !rows[0][requiredPermission]) {
return res.status(403).json({ message: 'Forbidden: Insufficient permissions.' });
}
next();
} catch (error) {
console.error('Permission check error:', error);
res.status(500).json({ message: 'Database error during permission check.' });
}
};
};
router.use(authenticateJWT);
// --- START: Date Management Routes ---
router.get('/enabled-dates', async (req, res) => {
router.get('/enabled-dates', checkPermission('view_all'), 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
@@ -39,7 +61,7 @@ export default function(db) {
});
// Definitive version using a dedicated database connection
router.post('/enabled-dates/update', async (req, res) => {
router.post('/enabled-dates/update', checkPermission('view_all'), async (req, res) => {
let connection; // Define connection here to ensure it's accessible in the 'finally' block
try {
const { datesToEnable, datesToDisable } = req.body;
@@ -77,7 +99,7 @@ export default function(db) {
// --- ATTENDANCE & REPORTING ---
router.get('/failed-records', async (req, res) => {
router.get('/failed-records', checkPermission('view_all'), async (req, res) => {
try {
const { search = '', startDate, endDate } = req.query;
if (!startDate || !endDate) {
@@ -112,7 +134,7 @@ export default function(db) {
}
});
router.get('/failed-records/details', async (req, res) => {
router.get('/failed-records/details', checkPermission('view_all'), async (req, res) => {
try {
const { workerId, startDate, endDate } = req.query;
if (!workerId || !startDate || !endDate) {
@@ -139,7 +161,7 @@ export default function(db) {
});
// GET attendance records with a modified query to avoid the MySQL 5.7 bug
router.get('/attendance-records/export-raw', async (req, res) => {
router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
@@ -177,7 +199,7 @@ export default function(db) {
}
});
router.post('/add-record', authenticateJWT, async (req, res) => {
router.post('/add-record', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId, eventType, timestamp, notes } = req.body
@@ -212,7 +234,7 @@ export default function(db) {
}
})
router.get('/attendance-records/export', async (req, res) => {
router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate } = req.query;
if (!startDate || !endDate) {
@@ -295,7 +317,7 @@ export default function(db) {
}
});
router.get('/attendance-records', async (req, res) => {
router.get('/attendance-records', checkPermission('view_all'), async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query;
if (!workerIds) {
@@ -351,22 +373,107 @@ export default function(db) {
// --- All other manager routes remain the same ---
// GET a specific manager's permissions
router.get('/permissions/:id', async (req, res) => {
try {
const requesterId = req.user.id;
const targetId = parseInt(req.params.id, 10);
// Check if the user is trying to access their own permissions
if (requesterId !== targetId) {
// If not, check if they have permission to manage permissions
const [permissionRows] = await db.execute(
'SELECT can_manage_permissions FROM manager_permissions WHERE manager_id = ?',
[requesterId]
);
if (permissionRows.length === 0 || !permissionRows[0].can_manage_permissions) {
return res.status(403).json({ message: 'Forbidden: Insufficient permissions to view others\' permissions.' });
}
}
// If they are accessing their own, or have permission, fetch the target's permissions
const [rows] = await db.execute(
'SELECT * FROM manager_permissions WHERE manager_id = ?',
[targetId]
);
if (rows.length === 0) {
// If no permissions are set, return a default set of all false
const [fields] = await db.execute('DESCRIBE manager_permissions');
const defaultPermissions = fields.reduce((acc, field) => {
if (field.Field !== 'manager_id') {
acc[field.Field] = 0; // Use 0 for false
}
return acc;
}, {});
return res.json(defaultPermissions);
}
// Convert buffer values to booleans
const permissions = Object.entries(rows[0]).reduce((acc, [key, value]) => {
if (key !== 'manager_id') {
acc[key] = Boolean(value);
}
return acc;
}, {});
res.json(permissions);
} catch (error) {
console.error('Get manager permissions error:', error);
res.status(500).json({ message: 'Database error fetching manager permissions.', details: error.message });
}
});
// PUT (update) a manager's permissions
router.put('/permissions/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const permissions = req.body;
const fields = [
'view_all', 'edit_workers', 'manage_resources', 'manager_permissions'
];
const values = fields.map(field => permissions[field] || false);
// Convert to new simplified permissions schema
const query = `
INSERT INTO manager_permissions (manager_id, view_all, edit_workers, manage_resources, manager_permissions)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
view_all = VALUES(view_all),
edit_workers = VALUES(edit_workers),
manage_resources = VALUES(manage_resources),
manager_permissions = VALUES(manager_permissions)
`;
const queryParams = [id, ...values];
await db.execute(query, queryParams);
res.status(200).json({ message: 'Permissions updated successfully.' });
} catch (error) {
console.error('Update manager permissions error:', error);
res.status(500).json({ message: 'Database error updating manager permissions.', details: error.message });
}
});
// GET all workers with filtering and pagination
router.get('/workers', async (req, res) => {
router.get('/workers', checkPermission('view_all'), 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
SELECT w.id, w.username, w.full_name, w.department, w.position, w.created_at, w.status
FROM workers w
`;
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
const params = [];
const countParams = [];
let whereClauses = ["w.role = 'worker'"];
let whereClauses = ["w.role = 'worker'", "w.status != 'deleted'"]; // Filter out soft-deleted workers
if (search) {
whereClauses.push(`(w.full_name LIKE ? OR w.department LIKE ?)`);
@@ -393,8 +500,91 @@ export default function(db) {
}
});
// GET all managers with their permissions
router.get('/managers', checkPermission('manager_permissions'), 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, w.status,
mp.*
FROM workers w
LEFT JOIN manager_permissions mp ON w.id = mp.manager_id
`;
let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`;
const params = [];
const countParams = [];
let whereClauses = ["w.role = 'manager'", "w.status != 'deleted'"];
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 [managers] = await db.execute(baseQuery, params);
const [[{ totalCount }]] = await db.execute(countQuery, countParams);
res.json({ managers, totalCount });
} catch (error) {
console.error('Get managers error:', error);
res.status(500).json({ message: 'Database error fetching managers.', details: error.message });
}
});
// POST (add) a new manager
router.post('/managers', checkPermission('manager_permissions'), async (req, res) => {
try {
const { username, password, fullName, department, position } = 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, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, hashedPassword, fullName, 'manager', department, position, 'active']
);
// Set default view_all permission
await db.execute(
'INSERT INTO manager_permissions (manager_id, view_all) VALUES (?, ?)',
[result.insertId, true]
);
res.status(201).json({
id: result.insertId,
username,
fullName,
role: 'manager',
department,
position,
status: 'active',
view_all: true
});
} catch (error) {
console.error('Add manager 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 manager.', details: error.message });
}
});
// POST (add) a new worker
router.post('/workers', async (req, res) => {
router.post('/workers', checkPermission('edit_workers'), async (req, res) => {
try {
const { username, password, fullName, department, position, role = 'worker' } = req.body;
if (!username || !password || !fullName) {
@@ -402,10 +592,10 @@ export default function(db) {
}
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]
'INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[username, hashedPassword, fullName, role, department, position, 'active'] // Default status to 'active'
);
res.status(201).json({ id: result.insertId, username, fullName, role, department, position });
res.status(201).json({ id: result.insertId, username, fullName, role, department, position, status: 'active' });
} catch (error) {
console.error('Add worker error:', error);
if (error.code === 'ER_DUP_ENTRY') {
@@ -415,23 +605,132 @@ export default function(db) {
}
});
// DELETE a worker
router.delete('/workers/:id', async (req, res) => {
// Soft DELETE a worker (update status to 'deleted')
router.delete('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [id]);
const [result] = await db.execute("UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'worker'", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
return res.status(404).json({ message: 'Worker not found or already deleted.' });
}
res.status(204).send(); // Maintain existing response for client compatibility
} catch (error) {
console.error('Soft delete worker error:', error);
res.status(500).json({ message: 'Database error soft deleting worker.', details: error.message });
}
});
// Soft DELETE a manager (update status to 'deleted')
router.delete('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute("UPDATE workers SET status = 'deleted' WHERE id = ? AND role = 'manager'", [id]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found or already deleted.' });
}
res.status(204).send();
} catch (error) {
console.error('Delete worker error:', error);
res.status(500).json({ message: 'Database error deleting worker.', details: error.message });
console.error('Soft delete manager error:', error);
res.status(500).json({ message: 'Database error soft deleting manager.', details: error.message });
}
});
// PUT (update) a worker's details (department, position, status)
router.put('/workers/:id', checkPermission('edit_workers'), async (req, res) => {
try {
const { id } = req.params;
const { department, position, status } = req.body;
// Basic validation
if (!department && !position && !status) {
return res.status(400).json({ message: 'No update information provided.' });
}
if (status && !['active', 'inactive'].includes(status)) {
return res.status(400).json({ message: 'Invalid status value.' });
}
let updateQuery = 'UPDATE workers SET';
const params = [];
const fieldsToUpdate = [];
if (department) {
fieldsToUpdate.push('department = ?');
params.push(department);
}
if (position) {
fieldsToUpdate.push('position = ?');
params.push(position);
}
if (status) {
fieldsToUpdate.push('status = ?');
params.push(status);
}
updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'worker'`;
params.push(id);
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Worker details updated successfully.' });
} catch (error) {
console.error('Update worker details error:', error);
res.status(500).json({ message: 'Database error updating worker details.', details: error.message });
}
});
// PUT (update) a manager's details (department, position, status)
router.put('/managers/:id', checkPermission('manager_permissions'), async (req, res) => {
try {
const { id } = req.params;
const { department, position, status } = req.body;
// Basic validation
if (!department && !position && !status) {
return res.status(400).json({ message: 'No update information provided.' });
}
if (status && !['active', 'inactive'].includes(status)) {
return res.status(400).json({ message: 'Invalid status value.' });
}
let updateQuery = 'UPDATE workers SET';
const params = [];
const fieldsToUpdate = [];
if (department) {
fieldsToUpdate.push('department = ?');
params.push(department);
}
if (position) {
fieldsToUpdate.push('position = ?');
params.push(position);
}
if (status) {
fieldsToUpdate.push('status = ?');
params.push(status);
}
updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'manager'`;
params.push(id);
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found.' });
}
res.status(200).json({ message: 'Manager details updated successfully.' });
} catch (error) {
console.error('Update manager details error:', error);
res.status(500).json({ message: 'Database error updating manager details.', details: error.message });
}
});
// PUT (update) a worker's password
router.put('/workers/:workerId/password', async (req, res) => {
router.put('/workers/:workerId/password', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId } = req.params;
const { newPassword } = req.body;
@@ -450,24 +749,57 @@ export default function(db) {
}
});
// PUT (clear) a worker's device UUID
router.put('/workers/:workerId/reset-device', async (req, res) => {
// PUT (update) a manager's password
router.put('/managers/:managerId/password', checkPermission('manager_permissions'), async (req, res) => {
try {
const { managerId } = 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 = 'manager'", [hashedPassword, managerId]);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Manager not found.' });
}
res.status(200).json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Update manager password error:', error);
res.status(500).json({ message: 'Database error updating manager password.', details: error.message });
}
});
// PUT (clear) a worker's device UUID and/or update status
router.put('/workers/:workerId/reset-device', checkPermission('edit_workers'), async (req, res) => {
try {
const { workerId } = req.params;
const [result] = await db.execute("UPDATE workers SET device_uuid = NULL WHERE id = ?", [workerId]);
const { status } = req.body; // Optional status field
let updateQuery = "UPDATE workers SET device_uuid = NULL";
const params = [workerId];
if (status && ['active', 'inactive', 'deleted'].includes(status)) {
updateQuery += ", status = ?";
params.unshift(status); // Add status to the beginning of params for correct order
}
updateQuery += " WHERE id = ?";
const [result] = await db.execute(updateQuery, params);
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found.' });
}
res.status(200).json({ message: 'Device registration cleared.' });
res.status(200).json({ message: 'Device registration cleared and/or status updated.' });
} catch (error) {
console.error('Reset device error:', error);
res.status(500).json({ message: 'Database error resetting device.', details: error.message });
console.error('Reset device/update status error:', error);
res.status(500).json({ message: 'Database error resetting device or updating status.', details: error.message });
}
});
// Geofence Management Routes
router.get('/geofences', async (req, res) => {
router.get('/geofences', checkPermission('view_all'), async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, coordinates, is_active, created_at FROM geofences ORDER BY created_at DESC'
@@ -483,7 +815,7 @@ export default function(db) {
}
});
router.post('/geofences', async (req, res) => {
router.post('/geofences', checkPermission('manage_resources'), async (req, res) => {
try {
const { name, coordinates } = req.body;
if (!name || !coordinates) {
@@ -508,7 +840,7 @@ export default function(db) {
}
});
router.put('/geofences/:id', async (req, res) => {
router.put('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const { is_active } = req.body;
@@ -533,7 +865,7 @@ export default function(db) {
}
});
router.delete('/geofences/:id', async (req, res) => {
router.delete('/geofences/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute('DELETE FROM geofences WHERE id = ?', [id]);
@@ -551,7 +883,7 @@ export default function(db) {
// QR Code Management Routes
router.get('/qr-codes', authenticateJWT, async (req, res) => {
router.get('/qr-codes', checkPermission('view_all'), async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC'
@@ -563,7 +895,7 @@ export default function(db) {
}
});
router.post('/qr-codes', authenticateJWT, async (req, res) => {
router.post('/qr-codes', checkPermission('manage_resources'), async (req, res) => {
try {
const { name } = req.body;
if (!name) return res.status(400).json({ message: 'QR Code name is required.' });
@@ -586,7 +918,7 @@ export default function(db) {
}
});
router.put('/qr-codes/:id', authenticateJWT, async (req, res) => {
router.put('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
// Handle both isActive (camelCase) and is_active (snake_case)
@@ -612,7 +944,7 @@ export default function(db) {
}
});
router.delete('/qr-codes/:id', authenticateJWT, async (req, res) => {
router.delete('/qr-codes/:id', checkPermission('manage_resources'), async (req, res) => {
try {
const { id } = req.params;
const [result] = await db.execute(
+29
View File
@@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIE8zCCA1ugAwIBAgIQGdkeqkj233eI/7av8ih4aTANBgkqhkiG9w0BAQsFADCB
kTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlM
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMM
MW1rY2VydCBNQUlMXG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lh
bikwHhcNMjUwNzAzMDg1NDI2WhcNMzUwNzAzMDg1NDI2WjCBkTEeMBwGA1UEChMV
bWtjZXJ0IGRldmVsb3BtZW50IENBMTMwMQYDVQQLDCpNQUlMXG1hc29uZ3lhbkBE
RVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikxOjA4BgNVBAMMMW1rY2VydCBNQUlM
XG1hc29uZ3lhbkBERVNLVE9QLUlRVThEREQgKG1hc29uZ3lhbikwggGiMA0GCSqG
SIb3DQEBAQUAA4IBjwAwggGKAoIBgQC5YL+VRL/bDBg0SP78IZTCemeLr7Q4Zxtg
8MiaWrDnh6ssVFzmAY3PEnfTdSL/j0JV2I0cSZhmMkUAzoo7136paLA3aGD4QP0B
fDEt6xQZF30U3bRhTglEY8a1zhy6fJGTYOcl2/OTbS0q90fEaLx8wkVa0lf/2wA7
fYG65BSu9CgTdob6NBWbI3Jpsesxd+36WZCqa6ZPSk07nXozqjMFsG8CThr1Wmei
mZJZF6+ji0mI6RqiqgdWrKBp2FZbPERQS+QfYfKD5/N0cWpwUAxejSLlPxU886Ns
Tcld9vxHQjzcE0afJe7rO4IrzzIeL1oLsz3xhEBgn8JCUeWbU12pk+9j1z+/M0+U
LUt/g+cwHk8fKl7qoL1ydR7afDdFBR8ns+g5l40ZE/uwhgQA8uTsi2E18B5agAtQ
C6+dJC4bMiVn9iyCeQmPKS+xw4YOVmn0yfrkqRLRgSZDjQEd4pUAep4J/8WbI1BY
lNqRwmqBcLuuyQLpExlMBYPMWiWYBakCAwEAAaNFMEMwDgYDVR0PAQH/BAQDAgIE
MBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFElRm5C15845O14vXvSvwjxw
tiJEMA0GCSqGSIb3DQEBCwUAA4IBgQAIjI0pKdY1/NKIaCg0WuQcWh8/noTjqYdl
7RDJ+JQZB1W0SkN7XvSLiRcEroWalbq9BMwE/bwV9jcgW1NvQ+00VzUWKh0r9z3B
xjEnKnK+1pEXRdBfkG6bVi2XehNs7KOvqR07xv7o9GdB41R7TSo1Vr228ot82FNO
6B2iDumfIr9RESsx8nVntHvRuFTee/DlhVUEgJPWmw0Kwcewmd2p5XNijdA2V2nI
zwhUxQuuu0LtV8RXmBi5vDanrJHwZZ1kFvGG9SGiVNx6aEtGBjTMIFRpQyLFzeq9
TPbVLsEGjvi8wLqO8U/aj56BEkFNKAx0idohgyfF2qohRMXoL0MRtEQIpJdL2kMP
Gqg1aY7MWooEM9swji1hHuoDwLriVNS6W3LvT9qXWlI3e/J7f5aLT/QyP4VUW+4N
1oGUL54aXCMYymVXooU3QomakxCildlGbH0jdcf8uX8JVnI0Zeo3ftCmtf46Q+Lu
7Mhu4NO8kQGHH/m0wQwwahh+mBfwYwk=
-----END CERTIFICATE-----
+9 -2
View File
@@ -26,11 +26,18 @@ export default function(db) {
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]);
const [rows] = await db.execute('SELECT id, role, password_hash, status FROM workers WHERE username = ?', [username]);
if (rows.length === 0) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const user = rows[0];
// Check if the worker's status is 'active'
if (user.role === 'worker' && user.status !== 'active') {
// Return the same message as invalid credentials to avoid leaking information
return res.status(401).json({ message: 'Invalid credentials' });
}
const passwordMatch = await bcrypt.compare(password, user.password_hash);
if (!passwordMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
@@ -53,7 +60,7 @@ export default function(db) {
if (err) {
return res.status(403).json({ message: 'Invalid or expired token' });
}
req.user = user;
req.user = { ...user, id: user.userId }; // Correctly map userId to id
next();
});
} else {
-207
View File
File diff suppressed because one or more lines are too long
-13
View File
@@ -1,13 +0,0 @@
-- 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 -1
View File
@@ -7,7 +7,7 @@
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>Vite App</title>
<title>Ouji Kehadiran</title>
</head>
<body>
<div id="app"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+5
View File
@@ -1,6 +1,9 @@
<template>
<div
class="min-h-screen bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100 transition-colors duration-300">
<div class="fixed bottom-4 right-4 space-y-2 z-50">
<component v-for="toast in renderToasts()" :is="toast" :key="toast.key" />
</div>
<header
class="flex justify-between items-center px-4 py-3 sm:px-8 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm transition-colors duration-300">
<h1 class="text-xl sm:text-2xl font-bold">{{ $t('appTitle') }}</h1>
@@ -36,8 +39,10 @@
import { ref, onMounted, watch } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToast } from '@/composables/useToast'
const { locale } = useI18n()
const { renderToasts } = useToast()
const isDarkMode = ref(false)
const router = useRouter()
+10
View File
@@ -31,6 +31,16 @@ export async function apiFetch(endpoint, options = {}) {
// If the server sends back a JSON error, parse it.
if (contentType && contentType.includes('application/json')) {
errorData = await response.json();
if (response.status === 403) {
const event = new CustomEvent('show-toast', {
detail: {
severity: 'error',
summary: 'Permission Denied',
detail: errorData.message,
},
});
document.dispatchEvent(event);
}
// 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 {
+21 -10
View File
@@ -83,11 +83,16 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
import { apiFetch } from '@/api.js';
import { useToast } from '@/composables/useToast';
import { useI18n } from 'vue-i18n';
import 'leaflet/dist/leaflet.css';
import 'leaflet-draw/dist/leaflet.draw.css';
import L from 'leaflet';
import 'leaflet-draw';
const { t: $t } = useI18n();
const toast = useToast();
const geofences = ref([]);
const newGeofenceName = ref('');
const newGeofenceCoords = ref(null);
@@ -97,13 +102,11 @@ 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 {
@@ -111,6 +114,7 @@ const fetchGeofences = async () => {
displayGeofencesOnMap();
} catch (error) {
console.error('Failed to fetch geofences:', error);
toast.showToast($t('fetchGeofencesFailed'), 'error');
}
};
@@ -137,7 +141,7 @@ const initMap = () => {
map.addControl(drawControl);
map.on(L.Draw.Event.CREATED, (event) => {
drawnItems.clearLayers(); // Clear previous unsaved drawings
drawnItems.clearLayers();
const layer = event.layer;
drawnItems.addLayer(layer);
const latLngs = layer.getLatLngs()[0];
@@ -148,7 +152,6 @@ const initMap = () => {
const displayGeofencesOnMap = () => {
if (!map) return;
// Clear existing fence layers before redrawing
Object.values(fenceLayers).forEach(layer => map.removeLayer(layer));
geofences.value.forEach(fence => {
@@ -162,7 +165,7 @@ const displayGeofencesOnMap = () => {
});
};
const saveGeofence = async () => {
const saveGeofence = async () => { // eslint-disable-line no-unused-vars
if (!canSave.value) return;
try {
const newFence = await apiFetch('/api/managers/geofences', {
@@ -170,15 +173,19 @@ const saveGeofence = async () => {
body: JSON.stringify({ name: newGeofenceName.value, coordinates: newGeofenceCoords.value })
});
geofences.value.unshift(newFence);
startOver(); // Use startOver to clear the form
startOver();
displayGeofencesOnMap();
toast.showToast($t('geofenceSaved'), 'success');
} catch (error) {
console.error('Failed to save geofence:', error);
toast.showToast($t('saveGeofenceFailed'), 'error');
}
};
const deleteGeofence = async (id) => {
if (!confirm('Are you sure you want to delete this geofence?')) return;
const deleteGeofence = async (id) => { // eslint-disable-line no-unused-vars
const confirmed = await toast.showConfirm($t('confirmDeleteGeofence'))
if (!confirmed) return;
try {
await apiFetch(`/api/managers/geofences/${id}`, { method: 'DELETE' });
if (fenceLayers[id]) {
@@ -186,12 +193,14 @@ const deleteGeofence = async (id) => {
delete fenceLayers[id];
}
geofences.value = geofences.value.filter(g => g.id !== id);
toast.showToast($t('geofenceDeleted'), 'success');
} catch (error) {
console.error('Failed to delete geofence:', error);
toast.showToast($t('deleteGeofenceFailed'), 'error');
}
};
const toggleGeofenceStatus = async (fence) => {
const toggleGeofenceStatus = async (fence) => { // eslint-disable-line no-unused-vars
try {
const updatedFence = await apiFetch(`/api/managers/geofences/${fence.id}`, {
method: 'PUT',
@@ -204,12 +213,14 @@ const toggleGeofenceStatus = async (fence) => {
if (fenceLayers[fence.id]) {
fenceLayers[fence.id].setStyle({ color: updatedFence.is_active ? '#3388ff' : '#888888' });
}
toast.showToast($t('geofenceStatusUpdated'), 'success');
} catch (error) {
console.error('Failed to toggle geofence status:', error);
toast.showToast($t('updateGeofenceStatusFailed'), 'error');
}
};
const viewGeofenceOnMap = (fence) => {
const viewGeofenceOnMap = (fence) => { // eslint-disable-line no-unused-vars
if (fenceLayers[fence.id]) {
map.fitBounds(fenceLayers[fence.id].getBounds(), { padding: [50, 50] });
fenceLayers[fence.id].openPopup();
+22 -17
View File
@@ -66,6 +66,11 @@
<script setup>
import { ref, onMounted, computed } from 'vue';
import { apiFetch } from '@/api.js';
import { useToast } from '@/composables/useToast';
import { useI18n } from 'vue-i18n';
const { t: $t } = useI18n();
const toast = useToast();
const viewDate = ref(new Date());
const todayStr = new Date().toISOString().slice(0, 10);
@@ -74,14 +79,14 @@ 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 hasPendingChanges = computed(() => datesToEnable.value.size > 0 || datesToDisable.value.size > 0); // eslint-disable-line no-unused-vars
const sortedEnableList = computed(() => Array.from(datesToEnable.value).sort()); // eslint-disable-line no-unused-vars
const sortedDisableList = computed(() => Array.from(datesToDisable.value).sort()); // eslint-disable-line no-unused-vars
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' }));
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const monthYear = computed(() => viewDate.value.toLocaleString('default', { month: 'long', year: 'numeric' })); // eslint-disable-line no-unused-vars
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // eslint-disable-line no-unused-vars
const calendarGrid = computed(() => {
const calendarGrid = computed(() => { // eslint-disable-line no-unused-vars
const year = viewDate.value.getFullYear();
const month = viewDate.value.getMonth();
const firstDayOfMonth = new Date(year, month, 1).getDay();
@@ -100,7 +105,7 @@ const calendarGrid = computed(() => {
return grid;
});
const getDayClasses = (day) => {
const getDayClasses = (day) => { // eslint-disable-line no-unused-vars
if (!day.isCurrentMonth) return 'h-20';
const dateStr = day.id;
@@ -124,7 +129,6 @@ const getDayClasses = (day) => {
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');
}
@@ -132,8 +136,7 @@ const getDayClasses = (day) => {
return classes;
};
function onDayClick(day) {
function onDayClick(day) { // eslint-disable-line no-unused-vars
const dateStr = day.id;
const isOriginallyEnabled = originalEnabledDates.value.has(dateStr);
@@ -148,8 +151,9 @@ function onDayClick(day) {
}
}
async function applyChanges() {
if (!confirm('Are you sure you want to apply these changes to the work schedule?')) return;
async function applyChanges() { // eslint-disable-line no-unused-vars
const confirmed = await toast.showConfirm($t('confirmApplyChanges'))
if (!confirmed) return;
try {
await apiFetch('/api/managers/enabled-dates/update', {
@@ -161,10 +165,10 @@ async function applyChanges() {
});
await fetchEnabledDates();
discardChanges();
alert('Work schedule updated successfully!');
toast.showToast($t('scheduleUpdateSuccess'), 'success');
} catch (error) {
console.error('Failed to apply changes:', error);
alert('Failed to update schedule. Please try again.');
toast.showToast($t('scheduleUpdateFailed'), 'error');
}
}
@@ -173,10 +177,10 @@ function discardChanges() {
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 prevMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() - 1)); // eslint-disable-line no-unused-vars
const nextMonth = () => viewDate.value = new Date(viewDate.value.setMonth(viewDate.value.getMonth() + 1)); // eslint-disable-line no-unused-vars
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, {
const formatDate = (dateStr) => new Date(dateStr + 'T00:00:00').toLocaleDateString(undefined, { // eslint-disable-line no-unused-vars
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
@@ -186,6 +190,7 @@ async function fetchEnabledDates() {
originalEnabledDates.value = new Set(dates);
} catch (error) {
console.error('Failed to fetch enabled dates:', error);
toast.showToast($t('fetchDatesFailed'), 'error');
}
}
+441
View File
@@ -0,0 +1,441 @@
<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('managerRoster') }}</h2>
<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" />
<button @click="showAddManager = true" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md">
{{ $t('addManager') }}
</button>
</div>
<div v-if="showAddManager" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-md">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('addManager') }}</h3>
<button @click="closeAddManagerModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('fullName') }}</label>
<input type="text" v-model="newManager.fullName" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('username') }}</label>
<input type="text" v-model="newManager.username" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('password') }}</label>
<input type="password" v-model="newManager.password" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
<input type="text" v-model="newManager.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
<input type="text" v-model="newManager.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<button @click="addManager" :disabled="!isManagerFormValid || addingManager" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
{{ addingManager ? $t('adding') : $t('addManager') }}
</button>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full 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('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('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="manager in managers" :key="manager.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">{{ manager.full_name }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ manager.username }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ manager.department }}</td>
<td class="px-4 py-3">
<span :class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': manager.status === 'active',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': manager.status === 'inactive',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': manager.status === 'deleted'
}" class="px-2.5 py-0.5 rounded-full text-xs font-medium capitalize">
{{ manager.status }}
</span>
</td>
<td class="px-4 py-3 flex justify-end">
<button @click="openSettingsModal(manager)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ $t('settings') }}
</button>
</td>
</tr>
<tr v-if="managers.length === 0">
<td colspan="5" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? $t('loadingManagers') : $t('noManagersFound') }}
</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>
<div class="flex items-center gap-2">
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
</div>
<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="isSettingsModalVisible" 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-4xl">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('managerSettings') }}</h3>
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
<div class="mb-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
<input type="text" v-model="editingManager.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
<input type="text" v-model="editingManager.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
<div class="space-y-3">
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('managerStatus') }}</h4>
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="editingManager.isActive" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 border border-red-100 dark:border-red-900/30">
<div>
<div class="flex justify-between items-center">
<div>
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p>
</div>
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
{{ $t('delete') }}
</button>
</div>
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p>
<div class="flex justify-end gap-2">
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{{ $t('cancel') }}
</button>
<button @click="deleteManager(editingManager.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
{{ $t('confirm') }}
</button>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('managerPermissions') }}</h4>
<div class="space-y-4">
<div v-for="permission in permissionsList" :key="permission.key" class="flex items-center justify-between">
<span class="text-gray-700 dark:text-gray-300">{{ $t(permission.key) }}</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="editingManager[permission.key]" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button @click="closeSettingsModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
{{ $t('cancel') }}
</button>
<button @click="saveManagerSettings" class="bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors" :disabled="saving">
{{ saving ? $t('saving') : $t('saveChanges') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { apiFetch } from '@/api.js';
import { useToast } from '@/composables/useToast';
const managers = ref([]);
const loading = ref(false);
const errorMessage = ref('');
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = ref(20);
const totalManagers = ref(0);
const jumpToPageInput = ref(1);
const isSettingsModalVisible = ref(false);
const editingManager = ref(null);
const saving = ref(false);
const newPassword = ref('');
const confirmNewPassword = ref('');
const passwordErrorMessage = ref('');
const passwordSuccessMessage = ref('');
const showDeleteConfirm = ref(false);
const showAddManager = ref(false);
const addingManager = ref(false);
const newManager = ref({
fullName: '',
username: '',
password: '',
department: '',
position: ''
});
const toast = useToast();
const isManagerFormValid = computed(() =>
newManager.value.fullName &&
newManager.value.username &&
newManager.value.password
);
const permissionsList = ref([
{ key: 'view_all', label: 'View All (Workers, Reports, Alerts, Geofences, QR Codes, Kill Switch)' },
{ key: 'edit_workers', label: 'Edit Worker Accounts' },
{ key: 'manage_resources', label: 'Manage Resources (Geofences & QR Codes)' },
{ key: 'manager_permissions', label: 'Manage Managers (Add/Edit/Delete & Permissions)' },
]);
const totalPages = computed(() => {
const pages = Math.ceil(totalManagers.value / pageSize.value);
return pages < 1 ? 1 : pages;
});
watch(searchQuery, () => fetchManagers(1));
const fetchManagers = async (page = currentPage.value) => {
loading.value = true;
try {
const data = await apiFetch(`/api/managers/managers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`);
managers.value = data.managers.map(manager => {
// Map old permissions to new structure if needed
const managerPermissions = {
view_all: Boolean(manager.view_all ||
(manager.can_view_workers && manager.can_view_reports && manager.can_view_alerts &&
manager.can_view_geofences && manager.can_view_qrcodes && manager.can_manage_killswitch)),
edit_workers: Boolean(manager.edit_workers || manager.can_edit_workers),
manage_resources: Boolean(manager.manage_resources ||
(manager.can_manage_geofences || manager.can_manage_qrcodes)),
manager_permissions: Boolean(manager.manager_permissions ||
(manager.can_manage_permissions || manager.can_edit_managers || manager.can_delete_managers))
};
return { ...manager, ...managerPermissions, isActive: manager.status === 'active' };
});
totalManagers.value = data.totalCount;
currentPage.value = page;
} catch (_err) {
errorMessage.value = 'Failed to fetch managers.';
managers.value = [];
totalManagers.value = 0;
toast.showToast('Failed to fetch managers.', 'error');
} finally {
loading.value = false;
}
};
const changePage = (page) => {
if (page > 0 && page <= totalPages.value) {
fetchManagers(page);
}
};
const jumpToPage = () => {
const page = Number(jumpToPageInput.value);
if (!isNaN(page) && page >= 1 && page <= totalPages.value) {
changePage(page);
} else {
jumpToPageInput.value = currentPage.value;
}
};
const openSettingsModal = (manager) => {
editingManager.value = { ...manager };
isSettingsModalVisible.value = true;
};
const closeSettingsModal = () => {
isSettingsModalVisible.value = false;
editingManager.value = null;
newPassword.value = '';
confirmNewPassword.value = '';
passwordErrorMessage.value = '';
passwordSuccessMessage.value = '';
showDeleteConfirm.value = false;
};
const saveManagerSettings = async () => {
if (!editingManager.value) return;
saving.value = true;
passwordErrorMessage.value = '';
passwordSuccessMessage.value = '';
try {
// Save permissions - convert to new structure
const permissionsToSave = {
view_all: editingManager.value.view_all || false,
edit_workers: editingManager.value.edit_workers || false,
manage_resources: editingManager.value.manage_resources || false,
manager_permissions: editingManager.value.manager_permissions || false
};
const permissionsResponse = await apiFetch(`/api/managers/permissions/${editingManager.value.id}`, {
method: 'PUT',
body: JSON.stringify(permissionsToSave),
});
if (!permissionsResponse.success) {
throw new Error(permissionsResponse.message || 'Failed to update permissions.');
}
// Save manager details
await apiFetch(`/api/managers/managers/${editingManager.value.id}`, {
method: 'PUT',
body: JSON.stringify({
department: editingManager.value.department,
position: editingManager.value.position,
status: editingManager.value.isActive ? 'active' : 'inactive',
}),
});
// Save password if changed
if (newPassword.value) {
if (newPassword.value !== confirmNewPassword.value) {
throw new Error('Passwords do not match.');
}
await apiFetch(`/api/managers/managers/${editingManager.value.id}/password`, {
method: 'PUT',
body: JSON.stringify({ newPassword: newPassword.value }),
});
}
await fetchManagers(currentPage.value);
closeSettingsModal();
toast.showToast('Manager settings saved successfully!', 'success');
} catch (err) {
passwordErrorMessage.value = err.message || 'Failed to save settings.';
toast.showToast(err.message || 'Failed to save settings.', 'error');
} finally {
saving.value = false;
}
};
const closeAddManagerModal = () => {
showAddManager.value = false;
newManager.value = {
fullName: '',
username: '',
password: '',
department: '',
position: ''
};
};
const addManager = async () => {
if (!isManagerFormValid.value) return;
addingManager.value = true;
try {
// First create the manager
const response = await apiFetch('/api/managers/managers', {
method: 'POST',
body: JSON.stringify({
...newManager.value,
role: 'manager'
}),
});
if (!response || !response.id) {
throw new Error('Failed to create manager - invalid response from server');
}
// Then set default permissions (view_all = true)
try {
await apiFetch(`/api/managers/permissions/${response.id}`, {
method: 'PUT',
body: JSON.stringify({
view_all: true,
edit_workers: false,
manage_resources: false,
manager_permissions: false
}),
});
} catch (permError) {
// If permissions fail, delete the manager to maintain consistency
await apiFetch(`/api/managers/managers/${response.id}`, { method: 'DELETE' });
throw new Error('Failed to set default permissions: ' + (permError.message || 'Unknown error'));
}
await fetchManagers(1); // Auto-refetch the manager list after adding
closeAddManagerModal(); // Auto-close the modal after successful addition
toast.showToast('Manager added successfully!', 'success');
} catch (err) {
const displayMessage = err.message || err.sqlMessage || 'Error adding manager';
toast.showToast(displayMessage, 'error');
} finally {
addingManager.value = false;
}
};
const deleteManager = async (id) => {
try {
await apiFetch(`/api/managers/managers/${id}`, { method: 'DELETE' });
toast.showToast('Manager Deleted successfully.', 'success');
// Adjust page number if the last manager on a page was deleted
fetchManagers(managers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
closeSettingsModal();
} catch (_err) {
toast.showToast('Failed to delete manager.', 'error');
errorMessage.value = 'Failed to Delete manager.'; // This could also be removed if toast is sufficient
}
};
onMounted(() => {
fetchManagers();
});
</script>
+284 -73
View File
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-8 pb-20">
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<section v-if="permissions.edit_workers" 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-3 lg:grid-cols-6 gap-4 items-end">
<div class="flex flex-col gap-2">
@@ -23,11 +23,8 @@
<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" />
<span class="text-gray-700 dark:text-gray-300">{{ $t('asManager') }}</span>
</label>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300 invisible">{{ $t('addUser') }}</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">
{{ loading ? $t('adding') : $t('addUser') }}
</button>
@@ -38,9 +35,12 @@
<section class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 class="text-xl font-semibold mb-6 text-gray-800 dark:text-white">{{ $t('workerRoster') }}</h2>
<div class="mb-6 flex flex-col sm:flex-row gap-4 sm:items-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="mb-6 flex flex-col sm:flex-row gap-4 sm:items-end justify-between">
<div class="flex-grow">
<input type="text" id="search-roster" v-model="searchQuery" :placeholder="$t('searchByNameOrDepartment')" class="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 w-full shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
</div>
<div class="flex items-end gap-4">
<div class="flex flex-col gap-2">
<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" />
@@ -49,7 +49,7 @@
<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">
<button @click="exportWorkHours" :disabled="!exportFilters.startDate || !exportFilters.endDate || exportLoading" class="bg-green-600 hover:bg-green-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
{{ exportLoading ? $t('exporting') : $t('exportAll') }}
</button>
</div>
@@ -65,7 +65,7 @@
<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">{{ $t('status') }}</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>
@@ -78,17 +78,29 @@
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.username }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.department }}</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ worker.position }}</td>
<td class="px-4 py-3">
<span :class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200': worker.status === 'active',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200': worker.status === 'inactive',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200': worker.status === 'deleted'
}" class="px-2.5 py-0.5 rounded-full text-xs font-medium capitalize">
{{ worker.status }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 dark:text-white">{{ new Date(worker.created_at).toLocaleDateString() }}</td>
<td class="px-4 py-3 flex justify-end gap-2 sm:gap-3 flex-wrap">
<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>
<button @click="openSettingsModal(worker)" class="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md text-sm font-medium transition-colors duration-200 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{{ $t('settings') }}
</button>
</td>
</tr>
<tr v-if="workers.length === 0">
<td colspan="7" class="text-center py-8 text-gray-500 dark:text-gray-400">
{{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
<td colspan="8" class="text-center py-8 text-gray-500 dark:text-gray-400"> {{ loading ? $t('loadingWorkers') : $t('noWorkersFound') }}
</td>
</tr>
</tbody>
@@ -96,37 +108,131 @@
</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>
<div class="flex items-center gap-2">
<input type="number" v-model.number="jumpToPageInput" @keyup.enter="jumpToPage" class="w-20 text-center border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1.5 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white" />
<span class="text-gray-700 dark:text-gray-200">/ {{ totalPages }}</span>
</div>
<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="isPasswordModalVisible" class="fixed inset-0 bg-gray-900 bg-opacity-60 flex justify-center items-center z-50 p-4">
<div v-if="isSettingsModalVisible" 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">
{{ $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" />
</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" />
</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>
</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">
{{ passwordLoading ? $t('saving') : $t('savePassword') }}
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold text-gray-800 dark:text-white">{{ $t('employeeSettings') }}</h3>
<button @click="closeSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-6">
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('accountSettings') }}</h4>
<div class="mb-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('department') }}</label>
<input type="text" v-model="editingWorker.department" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('position') }}</label>
<input type="text" v-model="editingWorker.position" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('changePassword') }}</label>
<div class="space-y-3">
<input type="password" v-model="newPassword" :placeholder="$t('newPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
<input type="password" v-model="confirmNewPassword" :placeholder="$t('confirmNewPassword')" class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
</div>
</div>
<div class="flex items-center justify-between">
<div>
<h4 class="font-semibold text-lg mb-4 text-gray-800 dark:text-white">{{ $t('workerStatus') }}</h4>
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ $t('activeAccount') }}</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="editingWorker.isActive" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 border border-red-100 dark:border-red-900/30">
<div class="mb-4">
<div class="flex justify-between items-center">
<div>
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('clearDevice') }}</h5>
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('clearDeviceDescription') }}</p>
</div>
<button @click="showClearDeviceConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
{{ $t('clearDevice') }}
</button>
</div>
<div v-if="showClearDeviceConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmClearDevice') }}</p>
<div class="flex justify-end gap-2">
<button @click="showClearDeviceConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{{ $t('cancel') }}
</button>
<button @click="clearDevice(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
{{ $t('confirm') }}
</button>
</div>
</div>
</div>
<div>
<div class="flex justify-between items-center">
<div>
<h5 class="font-medium text-red-700 dark:text-red-300">{{ $t('delete') }}</h5>
<p class="text-xs text-red-600 dark:text-red-400/80">{{ $t('deleteDescription') }}</p>
</div>
<button @click="showDeleteConfirm = true" class="text-red-700 dark:text-red-300 hover:text-white hover:bg-red-600 dark:hover:bg-red-700 px-3 py-1 rounded-md text-sm font-medium border border-red-300 dark:border-red-700 transition-colors w-32">
{{ $t('delete') }}
</button>
</div>
<div v-if="showDeleteConfirm" class="mt-3 p-3 bg-white dark:bg-gray-800 rounded-md">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">{{ $t('confirmDelete') }}</p>
<div class="flex justify-end gap-2">
<button @click="showDeleteConfirm = false" class="px-3 py-1 rounded-md text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
{{ $t('cancel') }}
</button>
<button @click="deleteWorker(editingWorker.id)" class="px-3 py-1 rounded-md text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors">
{{ $t('confirm') }}
</button>
</div>
</div>
</div>
</div>
<div v-if="passwordErrorMessage || passwordSuccessMessage" class="text-center">
<p v-if="passwordErrorMessage" class="text-red-500 text-sm">{{ passwordErrorMessage }}</p>
<p v-if="passwordSuccessMessage" class="text-green-500 text-sm">{{ passwordSuccessMessage }}</p>
</div>
<button @click="saveWorkerSettings" :disabled="passwordLoading" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md transition-colors disabled:opacity-50">
{{ passwordLoading ? $t('saving') : $t('saveChanges') }}
</button>
</div>
</div>
</div>
<div v-if="isConfirmModalVisible" 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-sm">
<h3 class="text-xl font-bold mb-4 text-gray-800 dark:text-white">{{ confirmMessage }}</h3>
<div class="flex justify-end gap-3 mt-6">
<button @click="closeConfirmModal" class="bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-800 dark:text-white font-medium px-4 py-2 rounded-md transition-colors">
{{ $t('cancel') }}
</button>
<button @click="executeConfirmedAction" class="bg-red-500 hover:bg-red-600 text-white font-medium px-4 py-2 rounded-md transition-colors">
{{ $t('confirm') }}
</button>
</div>
</form>
</div>
</div>
</div>
@@ -135,7 +241,12 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
import { apiFetch } from '@/api.js';
import { useToast } from '@/composables/useToast';
import { useRouter } from 'vue-router';
import { permissions } from '@/stores/permissions.js';
import { useI18n } from 'vue-i18n';
const { t: $t } = useI18n();
const router = useRouter();
@@ -148,30 +259,40 @@ 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 jumpToPageInput = ref(1);
const selectedWorkerIds = ref([]);
const isPasswordModalVisible = ref(false);
const editingWorkerPassword = ref(null);
const isSettingsModalVisible = ref(false);
const editingWorker = ref(null);
const newPassword = ref('');
const confirmNewPassword = ref('');
const passwordErrorMessage = ref('');
const passwordSuccessMessage = ref('');
const passwordLoading = ref(false);
const confirmAction = ref('');
const confirmMessage = ref('');
const isConfirmModalVisible = ref(false);
const exportFilters = ref({ startDate: '', endDate: '' });
const exportLoading = ref(false);
// Removed workerStatusLoading as it's no longer needed with integrated save
// --- COMPUTED ---
const isFormValid = computed(() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password);
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value));
const totalPages = computed(() => {
const pages = Math.ceil(totalWorkers.value / pageSize.value);
return pages < 1 ? 1 : pages; // Ensure at least 1 page
});
const isAllSelected = computed(() => workers.value.length > 0 && selectedWorkerIds.value.length === workers.value.length);
// --- WATCHERS ---
watch(searchQuery, () => fetchWorkers(1));
watch(currentPage, () => selectedWorkerIds.value = []);
watch(currentPage, (newPage) => {
selectedWorkerIds.value = [];
jumpToPageInput.value = newPage;
});
// --- METHODS ---
const fetchWorkers = async (page = currentPage.value) => {
@@ -180,74 +301,87 @@ const fetchWorkers = async (page = currentPage.value) => {
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;
// currentPage is already set to the requested page before fetch
} catch (_err) {
errorMessage.value = 'Failed to fetch workers.';
workers.value = [];
totalWorkers.value = 0;
currentPage.value = 1;
} finally {
loading.value = false;
}
};
const changePage = (page) => {
if (page > 0 && page <= totalPages.value) fetchWorkers(page);
if (page > 0 && page <= totalPages.value) {
currentPage.value = page;
fetchWorkers(page);
}
};
const jumpToPage = () => {
const page = Number(jumpToPageInput.value);
if (!isNaN(page) && page >= 1 && page <= totalPages.value) {
changePage(page);
} else {
jumpToPageInput.value = currentPage.value;
}
};
const addWorker = async () => {
const toast = useToast();
if (!isFormValid.value) return;
loading.value = true;
errorMessage.value = '';
try {
await apiFetch('/api/managers/workers', {
method: 'POST',
body: JSON.stringify({ ...newWorker.value, role: isManager.value ? 'manager' : 'worker' }),
body: JSON.stringify({ ...newWorker.value, role: 'worker' }),
});
await fetchWorkers(1);
newWorker.value = { fullName: '', username: '', password: '', department: '', position: '' };
isManager.value = false;
toast.showToast('Worker added successfully', 'success');
} catch (_err) {
errorMessage.value = _err.message || 'Error adding user.';
toast.showToast(_err.message || 'Error adding user.', 'error');
} finally {
loading.value = false;
}
};
const deleteWorker = async (id) => {
if (!confirm('Are you sure you want to delete this worker?')) return;
const toast = useToast();
const confirmed = await toast.showConfirm($t('confirmDeleteWorker'));
if (!confirmed) return;
try {
await apiFetch(`/api/managers/workers/${id}`, { method: 'DELETE' });
toast.showToast('Worker soft-deleted successfully.', 'success');
fetchWorkers(workers.value.length === 1 && currentPage.value > 1 ? currentPage.value - 1 : currentPage.value);
} catch (_err) {
errorMessage.value = 'Failed to delete worker.';
errorMessage.value = 'Failed to soft-delete worker.';
}
};
const clearDevice = async (workerId) => {
if (!confirm('Are you sure you want to clear the registered device for this worker?')) return;
const toast = useToast();
try {
await apiFetch(`/api/managers/workers/${workerId}/reset-device`, { method: 'PUT' });
alert('Worker device cleared successfully.');
toast.showToast('Worker device cleared successfully.', 'success');
} catch (_err) {
alert(_err.message || 'Failed to clear device.');
toast.showToast(_err.message || 'Failed to clear device.', 'error');
}
};
const openPasswordModal = (worker) => {
editingWorkerPassword.value = worker;
isPasswordModalVisible.value = true;
};
const closePasswordModal = () => {
isPasswordModalVisible.value = false;
editingWorkerPassword.value = null;
newPassword.value = '';
confirmNewPassword.value = '';
// Renamed and refactored updateWorkerPassword to saveWorkerSettings
const saveWorkerSettings = async () => {
const toast = useToast();
passwordErrorMessage.value = '';
passwordSuccessMessage.value = '';
passwordLoading.value = false;
};
let passwordUpdated = false;
let detailsUpdated = false;
toast.showToast('Saving settings...', 'info');
const updateWorkerPassword = async () => {
passwordErrorMessage.value = '';
// Handle password change
if (newPassword.value || confirmNewPassword.value) {
if (newPassword.value !== confirmNewPassword.value) {
passwordErrorMessage.value = 'Passwords do not match.';
return;
@@ -256,21 +390,96 @@ const updateWorkerPassword = async () => {
passwordErrorMessage.value = 'Password must be at least 6 characters long.';
return;
}
passwordUpdated = true;
}
// Handle details change (status, department, position)
const originalWorker = workers.value.find(w => w.id === editingWorker.value.id);
const newStatus = editingWorker.value.isActive ? 'active' : 'inactive';
if (
originalWorker.status !== newStatus ||
originalWorker.department !== editingWorker.value.department ||
originalWorker.position !== editingWorker.value.position
) {
detailsUpdated = true;
}
if (!passwordUpdated && !detailsUpdated) {
passwordErrorMessage.value = 'No changes to save.';
return;
}
passwordLoading.value = true;
try {
await apiFetch(`/api/managers/workers/${editingWorkerPassword.value.id}/password`, {
if (passwordUpdated) {
await apiFetch(`/api/managers/workers/${editingWorker.value.id}/password`, {
method: 'PUT',
body: JSON.stringify({ newPassword: newPassword.value }),
});
passwordSuccessMessage.value = 'Password updated successfully!';
setTimeout(closePasswordModal, 2000);
}
if (detailsUpdated) {
await apiFetch(`/api/managers/workers/${editingWorker.value.id}`, {
method: 'PUT',
body: JSON.stringify({
status: newStatus,
department: editingWorker.value.department,
position: editingWorker.value.position,
}),
});
if (passwordUpdated) {
passwordSuccessMessage.value += ' Worker details also updated.';
} else {
passwordSuccessMessage.value = 'Worker details updated successfully!';
}
}
await fetchWorkers(currentPage.value);
setTimeout(() => {
closeSettingsModal();
}, 2000);
} catch (_err) {
passwordErrorMessage.value = _err.message || 'Failed to update password.';
passwordErrorMessage.value = _err.message || 'Failed to save settings.';
} finally {
passwordLoading.value = false;
}
};
const openSettingsModal = (worker) => {
editingWorker.value = { ...worker, isActive: worker.status === 'active' }; // Initialize isActive for checkbox
isSettingsModalVisible.value = true;
};
const closeSettingsModal = () => {
isSettingsModalVisible.value = false;
editingWorker.value = null;
newPassword.value = '';
confirmNewPassword.value = '';
passwordErrorMessage.value = '';
passwordSuccessMessage.value = '';
showClearDeviceConfirm.value = false;
showDeleteConfirm.value = false;
};
const showClearDeviceConfirm = ref(false);
const showDeleteConfirm = ref(false);
const closeConfirmModal = () => {
isConfirmModalVisible.value = false;
confirmAction.value = '';
confirmMessage.value = '';
};
const executeConfirmedAction = () => {
if (confirmAction.value === 'clearDevice') {
clearDevice(editingWorker.value.id);
} else if (confirmAction.value === 'delete') {
deleteWorker(editingWorker.value.id);
}
closeConfirmModal();
};
const isWorkerSelected = (workerId) => selectedWorkerIds.value.includes(workerId);
const toggleWorkerSelection = (workerId) => {
@@ -284,7 +493,9 @@ const toggleSelectAll = (event) => {
};
const exportWorkHours = async () => {
const toast = useToast();
exportLoading.value = true;
toast.showToast('Exporting records...', 'info');
const { startDate, endDate } = exportFilters.value;
let workerIds = selectedWorkerIds.value.join(',');
@@ -305,7 +516,7 @@ const exportWorkHours = async () => {
a.remove();
window.URL.revokeObjectURL(url);
} catch (_err) {
alert('Failed to export records.');
toast.showToast('Failed to export records.', 'error');
} finally {
exportLoading.value = false;
}
+26 -17
View File
@@ -94,18 +94,24 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import { apiFetch } from '@/api.js'
import { useToast } from '@/composables/useToast'
const router = useRouter()
const toast = useToast()
const { t: $t } = useI18n()
const qrCodes = ref([])
const newQrName = ref('')
const newlyGeneratedQr = ref(null)
const newQrCanvas = ref(null)
import { permissions } from '@/stores/permissions.js'
onMounted(() => {
const userRole = sessionStorage.getItem('userRole')
if (userRole !== 'manager') {
if (userRole !== 'manager' || !permissions.value.view_all) {
router.push('/')
return
}
@@ -114,18 +120,17 @@ onMounted(() => {
const fetchQrCodes = async () => {
try {
// 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)
toast.showToast('Failed to fetch QR codes', 'error')
}
}
const addQrCode = async () => {
const addQrCode = async () => { // eslint-disable-line no-unused-vars
if (!newQrName.value) return
try {
// CORRECT: Get the new QR object directly
const newQr = await apiFetch('/api/managers/qr-codes', {
method: 'POST',
body: JSON.stringify({ name: newQrName.value }),
@@ -141,49 +146,52 @@ const addQrCode = async () => {
newQr.id,
{ width: 220, margin: 2, color: { dark: '#050505', light: '#FFFFFF' } },
(error) => {
if (error) console.error(error)
if (error) {
console.error(error)
toast.showToast('Failed to generate QR code image', 'error')
}
},
)
} catch (_err) {
console.error('Failed to add QR code:',_err)
toast.showToast('Failed to create QR code', 'error')
}
}
const toggleQrStatus = async (qr) => {
const toggleQrStatus = async (qr) => { // eslint-disable-line no-unused-vars
try {
// CORRECT: No need to check response, catch block will handle errors
await apiFetch(`/api/managers/qr-codes/${qr.id}`, {
method: 'PUT',
body: JSON.stringify({ isActive: !qr.is_active }),
})
// Update status locally on success
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
if (index !== -1) {
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
}
} catch (_err) {
console.error('Failed to update QR status:',_err)
toast.showToast('Failed to update QR code status', 'error')
}
}
const deleteQrCode = async (id) => {
if (!confirm($t('deleteQrConfirm'))) {
return
}
const deleteQrCode = async (id) => { // eslint-disable-line no-unused-vars
const confirmed = await toast.showConfirm($t('deleteQrConfirm'))
if (!confirmed) return
try {
// CORRECT: No need to check response, just await the call
await apiFetch(`/api/managers/qr-codes/${id}`, {
method: 'DELETE',
})
// Filter out the deleted QR code on success
qrCodes.value = qrCodes.value.filter((qr) => qr.id !== id)
toast.showToast('QR code deleted successfully', 'success')
} catch (_err) {
console.error('Failed to delete QR code:',_err)
toast.showToast('Failed to delete QR code', 'error')
}
}
const downloadQrCode = async (qr) => {
const downloadQrCode = async (qr) => { // eslint-disable-line no-unused-vars
try {
const dataUrl = await QRCode.toDataURL(qr.id, {
type: 'image/png',
@@ -198,8 +206,9 @@ const downloadQrCode = async (qr) => {
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} catch {
alert($t('qrDownloadError'))
} catch (_err) {
console.error('Failed to download QR code:', _err)
toast.showToast($t('qrDownloadError'), 'error')
}
}
</script>
+135
View File
@@ -0,0 +1,135 @@
<template>
<transition name="app-toast">
<div
v-if="visible"
class="fixed z-[9999] bottom-4 right-4 p-4 rounded-lg shadow-lg max-w-xs"
:class="{
'bg-green-100 text-green-800 border border-green-200': type === 'success',
'bg-red-100 text-red-800 border border-red-200': type === 'error',
'bg-blue-100 text-blue-800 border border-blue-200': type === 'info',
'bg-yellow-100 text-yellow-800 border border-yellow-200': type === 'warning',
'bg-gray-100 text-gray-800 border border-gray-200': type === 'default'
}"
>
<div class="flex items-start">
<div class="flex-shrink-0">
<svg
class="h-5 w-5"
:class="{
'text-green-500': type === 'success',
'text-red-500': type === 'error',
'text-blue-500': type === 'info',
'text-yellow-500': type === 'warning',
'text-gray-500': type === 'default'
}"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
v-if="type === 'success'"
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
<path
v-if="type === 'error'"
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
<path
v-if="type === 'info' || type === 'default'"
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z"
clip-rule="evenodd"
/>
<path
v-if="type === 'warning'"
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium">
{{ message }}
</p>
<div v-if="$slots.actions" class="mt-2 flex gap-2">
<slot name="actions"></slot>
</div>
</div>
<div class="ml-auto pl-3">
<button
@click="visible = false"
class="inline-flex rounded-md focus:outline-none"
:class="{
'text-green-500 hover:text-green-600 focus:text-green-600': type === 'success',
'text-red-500 hover:text-red-600 focus:text-red-600': type === 'error',
'text-blue-500 hover:text-blue-600 focus:text-blue-600': type === 'info',
'text-yellow-500 hover:text-yellow-600 focus:text-yellow-600': type === 'warning',
'text-gray-500 hover:text-gray-600 focus:text-gray-600': type === 'default'
}"
>
<span class="sr-only">Close</span>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" 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>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, watch } from 'vue';
defineOptions({
name: 'AppToast'
});
const props = defineProps({
message: {
type: String,
required: true
},
type: {
type: String,
default: 'default',
validator: (value) => ['success', 'error', 'info', 'warning', 'default'].includes(value)
},
duration: {
type: Number,
default: 5000
},
persistent: {
type: Boolean,
default: false
}
});
const visible = ref(true);
watch(visible, (newVal) => {
if (newVal && !props.persistent) {
setTimeout(() => {
visible.value = false;
}, props.duration);
}
});
</script>
<style>
.app-toast-enter-active,
.app-toast-leave-active {
transition: all 0.3s ease;
}
.app-toast-enter-from,
.app-toast-leave-to {
opacity: 0;
transform: translateY(20px);
}
</style>
+7 -7
View File
@@ -2,12 +2,11 @@
<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="mb-6 flex flex-col sm:flex-row sm:items-end gap-4 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 items-end 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" />
@@ -16,7 +15,7 @@
<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">
<button @click="fetchFailedRecords" :disabled="loadingReport" class="bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-2 rounded-md transition-colors duration-200 disabled:opacity-50">
{{ loadingReport ? $t('loading') : $t('fetchRecords') }}
</button>
</div>
@@ -67,7 +66,6 @@
</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">
@@ -109,8 +107,10 @@
import { ref, onMounted, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { apiFetch } from '@/api.js';
import { useToast } from '@/composables/useToast';
const { t: $t } = useI18n();
const toast = useToast();
// --- STATE ---
const searchQuery = ref('');
@@ -152,7 +152,7 @@ const fetchFailedRecords = async () => {
failedRecords.value = await apiFetch(url);
} catch (_err) {
console.error('Failed to fetch failed records',_err);
alert('Failed to fetch records.');
toast.showToast('Failed to fetch records.', 'error');
} finally {
loadingReport.value = false;
}
@@ -175,7 +175,7 @@ const showDetails = async (workerId, workerName) => {
showDetailModal.value = true;
} catch (_err) {
console.error('Failed to fetch details',_err);
alert('Failed to load details.');
toast.showToast('Failed to load details.', 'error');
}
};
+83
View File
@@ -0,0 +1,83 @@
import { ref, h } from 'vue';
import AppToast from '@/components/Toast.vue';
const toasts = ref([]);
export function useToast() {
const showToast = (message, type = 'default', duration = 5000) => {
const id = Date.now();
const toast = {
id,
message,
type,
duration
};
toasts.value.push(toast);
setTimeout(() => {
removeToast(id);
}, duration);
};
const removeToast = (id) => {
toasts.value = toasts.value.filter(toast => toast.id !== id);
};
const showConfirm = async (message) => {
return new Promise((resolve) => {
const id = Date.now();
const toast = {
id,
message,
type: 'info',
duration: 0,
actions: [
{
text: 'Confirm',
handler: () => {
removeToast(id);
resolve(true);
}
},
{
text: 'Cancel',
handler: () => {
removeToast(id);
resolve(false);
}
}
]
};
toasts.value.push(toast);
});
};
const renderToasts = () => {
return toasts.value.map(toast => {
return h(AppToast, {
key: toast.id,
message: toast.message,
type: toast.type,
duration: toast.duration,
persistent: !!toast.actions,
onClose: () => removeToast(toast.id)
}, {
actions: toast.actions ? () => toast.actions.map(action =>
h('button', {
class: 'px-3 py-1 text-sm rounded-md',
onClick: action.handler
}, action.text)
) : null
});
});
};
return {
showToast,
showConfirm,
renderToasts,
toasts
};
}
+54 -17
View File
@@ -1,5 +1,5 @@
{
"appTitle": "Clock-In/Out System",
"appTitle": "Attendance System",
"logout": "Logout",
"login": "Login",
"username": "Username",
@@ -34,7 +34,7 @@
"noClockHistory": "You have no clocking history.",
"clockHistoryFetchFail": "Failed to fetch clock history:",
"viewClockHistory": "View My Clock History",
"changePassword": "Change My Password",
"changePassword": "Change Password",
"successClockIn": "Successfully clocked in.",
"successClockOut": "Successfully clocked out.",
@@ -48,15 +48,15 @@
"tabPersonnel": "Personnel",
"tabAttendance": "Attendance",
"tabWarning": "Warnings",
"tabWarning": "Alerts",
"warningSettings": "Warning Settings",
"failedClockSummary": "Failed Clock Summary",
"failedClockSummary": "Failed Clock Events ",
"failedCount": "Failed Count",
"viewDetails": "View Details",
"fetchRecords": "Fetch Records",
"fetchRecords": "Show List",
"failedRecordsFor": "Failed Records for ",
"eventType": "Event Type",
"tabQrCodes": "QR Codes",
"tabQrCodes": "QR",
"uploadQrImage": "Upload QR Image",
"couldNotLoadWorkerInfo": "Could not load worker information",
@@ -136,11 +136,12 @@
"department": "Department",
"position": "Position",
"egJohnSmith": "e.g. John Smith",
"egJsmith": "e.g. jsmith",
"eg123456": "e.g. 123456",
"egJsmith": "e.g. 123456",
"eg123456": "eg. 123456",
"asManager": "As Manager",
"adding": "Adding...",
"addUser": "Add User",
"addManager": "Add Manager",
"manageTags": "Manage Tags",
"createNewTag": "Create New Tag",
"egTeam": "e.g. Team",
@@ -148,9 +149,9 @@
"egManager": "e.g. Manager",
"createTag": "Create Tag",
"tags": "Tags",
"workerRoster": "Worker Roster",
"searchByNameOrUsername": "Search by name or username",
"searchByNameOrDepartment": "Search by name or department",
"workerRoster": "Employee List",
"searchByNameOrUsername": "Search by name/username",
"searchByNameOrDepartment": "Search by name/department",
"filterByTag": "Filter by tag",
"clearFilter": "Clear filter",
"dateJoined": "Date Joined",
@@ -188,7 +189,7 @@
"newCodeCreated": "New Code Created!",
"saveQrInstruction": "Save this image or use the ID below. This will disappear on refresh.",
"id": "ID",
"existingQrCodes": "Existing QR Codes",
"existingQrCodes": "QR Code List",
"name": "Name",
"status": "Status",
"active": "Active",
@@ -202,12 +203,12 @@
"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.",
"createGeofence": "Add Geofence Area",
"drawInstruction": "Use the polygon tool to draw your area on the map. Click your starting point again to finish.",
"geofenceName": "Geofence Name",
"geofenceNamePlaceholder": "e.g., Main Warehouse Zone",
"saveGeofence": "Save Geofence",
"existingGeofences": "Existing Geofences",
"saveGeofence": "Save Area",
"existingGeofences": "Saved Area",
"view": "View",
"noGeofencesFound": "No Geofences Found",
"startOver" : "Start Over",
@@ -233,5 +234,41 @@
"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."
"error.criticalServer": "A critical server error occurred. Please contact support.",
"dangerZone": "Danger Zone",
"clearDeviceDescription": "Unlink Account with Device.",
"settings": "Settings",
"employeeSettings": "Employee Settings",
"accountSettings": "Account Settings",
"workerStatus": "Account Status",
"activeAccount": "Allow Login",
"deleteDescription": "User will be deleted.",
"saveChanges": "Save Changes",
"managerPermissions": "Manager",
"can_view_workers": "View Workers",
"can_edit_workers": "Manage Workers",
"can_view_alerts": "View Alerts",
"can_view_geofences": "View Geofences",
"can_manage_geofences": "Manage Geofences",
"can_view_qrcodes": "View QR Codes",
"can_manage_qrcodes": "Manage QR Codes",
"can_view_reports": "View Reports",
"can_manage_killswitch": "Manage Schedule",
"can_manage_permissions": "Manage Permissions",
"can_edit_managers": "Edit Managers",
"can_delete_managers": "Delete Managers",
"noManagersFound": "No managers found",
"managerRoster": "Manager Roster",
"loadingManagers": "Loading managers...",
"managerSettings": "Manager Settings",
"managerStatus": "Manager Status",
"confirmDeleteWorker": "Are you sure you want to delete this worker? This will soft-delete their account.",
"view_all": "View All",
"edit_workers": "Edit Workers",
"manage_resources": "Manage Resources",
"manager_permissions": "Manager Permissions",
"confirmDelete": "Are you sure you want to delete this?",
"confirm": "Confirm"
}
+54 -32
View File
@@ -1,7 +1,7 @@
{
"appTitle": "Sistem Kehadiran",
"logout": "Log Keluar",
"login": "Log Masuk",
"logout": "Keluar",
"login": "Masuk",
"username": "Nama Pengguna",
"password": "Kata Laluan",
"loggingIn": "Sedang log masuk...",
@@ -25,7 +25,7 @@
"out": "Keluar",
"cancel": "Batal",
"clockHistroy": "Sejarah Kehadiran",
"clockHistory": "Sejarah Kehadiran",
"viewMyClockHistory": "Lihat Sejarah Kehadiran Saya",
"changeMyPassword": "Tukar Kata Laluan Saya",
"updateYourPassword": "Tukar Kata Laluan Anda",
@@ -38,7 +38,7 @@
"successClockIn": "Berjaya masuk kerja.",
"successClockOut": "Berjaya keluar kerja.",
"qrFail": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
"qrFail": "QR tidak dapat dikesan. Sila cuba lagi.",
"geoFail": "Tidak dapat mengambil lokasi anda: {message}. Sila benarkan perkhidmatan lokasi.",
"successClock": "Berjaya daftar di {location}.",
"changePasswordTitle": "Tukar Kata Laluan",
@@ -54,10 +54,10 @@
"failedClockSummary": "Ringkasan Kegagalan Clock",
"failedCount": "Bilangan Gagal",
"viewDetails": "Lihat Butiran",
"fetchRecords": "Dapatkan Rekod",
"fetchRecords": "Lihat Senarai",
"failedRecordsFor": "Rekod Gagal untuk ",
"eventType": "Jenis Peristiwa",
"tabQrCodes": "Kod QR",
"tabQrCodes": "QR",
"uploadQrImage": "Muat Naik Imej QR",
"couldNotLoadWorkerInfo": "Tidak dapat memuatkan maklumat pekerja",
@@ -67,10 +67,10 @@
"errorOccurred": "Ralat telah berlaku",
"unableToStartCamera": "Tidak dapat menghidupkan kamera.",
"tryAgain": "Cuba Lagi",
"qrDetectedGettingLocation": "Kod QR dikesan. Mengambil lokasi...",
"qrDetectedGettingLocation": "QR dikesan. Mengambil lokasi...",
"geolocationNotSupported": "Geolokasi tidak disokong oleh pelayar anda.",
"unableToRetrieveLocation": "Tidak dapat mengambil lokasi anda. Sila semak kebenaran lokasi. (Butiran: {message})",
"qrNotDetectedTryAgain": "Kod QR tidak dapat dikesan. Sila cuba lagi.",
"qrNotDetectedTryAgain": "QR tidak dapat dikesan. Sila cuba lagi.",
"updatePassword": "Kemaskini Kata Laluan",
"passwordsNoMatch": "Kata laluan baharu tidak sepadan.",
"passwordTooShort": "Kata laluan baharu mesti sekurang-kurangnya 6 aksara.",
@@ -78,22 +78,22 @@
"passwordUpdateError": "Ralat semasa mengemaskini kata laluan.",
"attendanceLogFor": "Log Kehadiran untuk",
"addManualClockOut": "Tambah Clock-Out Manual",
"addManualClockOut": "Tambah Clock-Out Secara Manual",
"manualClockOutInstruction": "Gunakan borang ini jika pekerja lupa untuk clock-out. Acara terakhir mesti clock-in.",
"clockOutTime": "Masa Clock-Out",
"reason": "Sebab (cth: \"Lupa clock-out\")",
"enterBriefNote": "Masukkan nota ringkas",
"enterBriefNote": "Tulis nota(jika perlu)",
"addRecord": "Tambah Rekod",
"startDate": "Tarikh Mula",
"endDate": "Tarikh Tamat",
"startDate": "Dari",
"endDate": "Hingga",
"filterRecords": "Tapis Rekod",
"event": "Acara",
"timestamp": "Cap Masa",
"locationName": "Nama Lokasi",
"coordinates": "Koordinat",
"notes": "Nota",
"noRecordsFound": "Tiada rekod untuk tempoh ini.",
"noRecordsFound": "Belum ada rekod untuk tempod ini.",
"showOnMap": "Papar di peta",
"nA": "Tiada",
"pleaseSelectTimestamp": "Sila pilih cap masa untuk clock-out.",
@@ -131,16 +131,17 @@
"reportGenerationError": "Ralat semasa menjana laporan.",
"exportAll": "Eksport Semua",
"export": "Eksport",
"addNewUser": "Tambah Pengguna Baharu",
"addNewUser": "Tambah Pengguna Baru",
"fullName": "Nama Penuh",
"department": "Jabatan",
"position": "Jawatan",
"egJohnSmith": "cth. John Smith",
"egJsmith": "cth. jsmith",
"egJsmith": "cth. 123456",
"eg123456": "cth. 123456",
"asManager": "Sebagai Pengurus",
"adding": "Sedang menambah...",
"addUser": "Tambah Pengguna",
"addManager": "Tambah Pengurus",
"manageTags": "Urus Tag",
"createNewTag": "Cipta Tag Baharu",
"egTeam": "cth. Pasukan",
@@ -148,9 +149,9 @@
"egManager": "cth. Pengurus",
"createTag": "Cipta Tag",
"tags": "Tag",
"workerRoster": "Senarai Pekerja",
"workerRoster": "Deftar Pekerja",
"searchByNameOrUsername": "Cari mengikut nama atau nama pengguna",
"searchByNameOrDepartment": "Cari mengikut nama atau jabatan",
"searchByNameOrDepartment": " Cari nama atau jabatan",
"filterByTag": "Tapis mengikut tag",
"clearFilter": "Padam tapisan",
"dateJoined": "Tarikh Sertai",
@@ -181,33 +182,33 @@
"areYouSureDeleteTag": "Adakah anda pasti mahu memadam tag ini? Ia akan dikeluarkan daripada semua pekerja.",
"failedToDeleteTag": "Gagal memadam tag.",
"passwordsDoNotMatch": "Kata laluan tidak sepadan.",
"createQrCode": "Cipta Kod QR Baharu",
"qrCodeName": "Nama Kod QR",
"createQrCode": "Cipta QR Baharu",
"qrCodeName": "Nama QR",
"qrNamePlaceholder": "cth: 'Pintu Masuk Barat'",
"create": "Cipta",
"newCodeCreated": "Kod Baharu Telah Dicipta!",
"saveQrInstruction": "Simpan imej ini atau gunakan ID di bawah. Ini akan hilang selepas segar semula.",
"id": "ID",
"existingQrCodes": "Kod QR Sedia Ada",
"existingQrCodes": "Senarai Kod QR",
"name": "Nama",
"status": "Status",
"active": "Aktif",
"inactive": "Tidak Aktif",
"deactivate": "Nyahaktif",
"deactivate": "Tutup",
"activate": "Aktifkan",
"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.",
"noQrCodesFound": "Tiada QR dijumpai. Sila cipta di atas!",
"deleteQrConfirm": "Adakah anda pasti ingin memadam QR ini? Tindakan ini tidak boleh diundur.",
"qrDownloadError": "Maaf, 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.",
"createGeofence": "Lukis Zon Baharu",
"drawInstruction": "Guna alat polygon untuk lukis Kawasan di peta. Klik semula titik pertama untuk siap.",
"geofenceName": "Nama Geofence",
"geofenceNamePlaceholder": "cth., Zon Gudang Utama",
"saveGeofence": "Simpan Geofence",
"existingGeofences": "Geofences Sedia Ada",
"saveGeofence": "Simpan Kawasan",
"existingGeofences": "Kawasan Disimpan",
"view": "Lihat",
"noGeofencesFound": "Tiada Geofences Dijumpai",
"startOver" : "Mula Semula",
@@ -223,15 +224,36 @@
"statusClockedIn": "Anda Sudah Masuk Kerja",
"statusClockedOut": "Anda Sudah Keluar Kerja",
"scanToClockIn": "Imbas Kod QR untuk Masuk Kerja",
"scanToClockOut": "Imbas Kod QR untuk Keluar Kerja",
"scanToClockIn": "Imbas QR untuk Masuk Kerja",
"scanToClockOut": "Imbas 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.invalidQrCode": "Gagal masuk/keluar: 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."
"error.criticalServer": "Ralat kritikal pada pelayan telah berlaku. Sila hubungi sokongan.",
"dangerZone": "Zon Bahaya",
"clearDeviceDescription": "Ini akan memadam semua data pekerja dari peranti. Gunakan dengan berhati-hati.",
"settings": "Tetapan",
"employeeSettings": "Tetapan Pekerja",
"accountSettings": "Tetapan Akaun",
"workerStatus": "Status Pekerja",
"activeAccount": "Akaun Aktif",
"deleteDescription": "Tindakan ini tidak boleh diundur. Semua data akan dipadam secara kekal.",
"saveChanges": "Simpan Perubahan",
"confirmDeleteWorker": "Adakah anda pasti mahu memadam pekerja ini? Ini akan memadam akaun mereka secara lembut.",
"managerPermissions": "Pengurus",
"managerRoster": "Daftar Pengurus",
"noManagersFound": "Tiada pengurus dijumpai",
"loadingManagers": "Memuatkan pengurus...",
"managerSettings": "Tetapan Pengurus",
"managerStatus": "Status Pengurus",
"view_all": "Lihat Semua",
"edit_workers": "Sunting Pekerja",
"manage_resources": "Urus Sumber",
"manager_permissions": "Kebenaran Pengurus"
}
+4
View File
@@ -2,6 +2,8 @@ import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import PrimeVue from 'primevue/config';
import ToastService from 'primevue/toastservice';
import i18n from './i18n'
@@ -9,5 +11,7 @@ const app = createApp(App)
app.use(router)
app.use(i18n)
app.use(PrimeVue);
app.use(ToastService);
app.mount('#app')
+7
View File
@@ -4,6 +4,7 @@ import LoginView from '../views/LoginView.vue'
import ManagerDashboardView from '../views/ManagerDashboardView.vue'
// import WorkerHistoryView from '../views/WorkerHistoryView.vue'
import AttendanceRecordView from '../views/AttendanceRecordView.vue'
import ManagerPermissions from '../components/ManagerPermissions.vue'
// import ChangePasswordView from '../views/ChangePasswordView.vue'
const router = createRouter({
@@ -41,6 +42,12 @@ const router = createRouter({
component: AttendanceRecordView,
meta: { requiresAuth: true, role: 'manager' },
},
{
path: '/manager/permissions',
name: 'manager-permissions',
component: ManagerPermissions,
meta: { requiresAuth: true, role: 'manager' },
},
],
})
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue';
import { apiFetch } from '@/api.js';
export const permissions = ref({});
export async function fetchPermissions() {
const managerId = sessionStorage.getItem('userId');
if (!managerId) return;
try {
const data = await apiFetch(`/api/managers/permissions/${managerId}`);
// Map old permissions to new structure if needed
permissions.value = {
view_all: Boolean(data.view_all ||
(data.can_view_workers && data.can_view_reports && data.can_view_alerts &&
data.can_view_geofences && data.can_view_qrcodes && data.can_manage_killswitch)),
edit_workers: Boolean(data.edit_workers || data.can_edit_workers),
manage_resources: Boolean(data.manage_resources ||
(data.can_manage_geofences || data.can_manage_qrcodes)),
manager_permissions: Boolean(data.manager_permissions ||
(data.can_manage_permissions || data.can_edit_managers || data.can_delete_managers))
};
} catch (error) {
console.error('Failed to fetch permissions:', error);
// Set default permissions to false if fetch fails
permissions.value = {
view_all: false,
edit_workers: false,
manage_resources: false,
manager_permissions: false
};
}
}
+37 -11
View File
@@ -2,7 +2,7 @@
<div class="max-w-full mx-auto px-4 py-4">
<div
class="flex flex-wrap justify-center gap-2 sm:gap-4 border-b-2 border-gray-200 dark:border-gray-700 mb-6 pb-2 relative z-10">
<button @click="activeTab = 'personnel'" :class="{
<button v-if="permissions.view_all || permissions.edit_workers" @click="activeTab = 'personnel'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'personnel',
'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':
@@ -11,7 +11,7 @@
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 = 'warning'" :class="{
<button v-if="permissions.view_all" @click="activeTab = 'warning'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
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':
@@ -20,7 +20,7 @@
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('tabWarning') }}
</button>
<button @click="activeTab = 'qr'" :class="{
<button v-if="permissions.view_all || permissions.manage_resources" @click="activeTab = 'qr'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'qr',
'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':
@@ -29,7 +29,7 @@
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>
<button @click="activeTab = 'geofencing'" :class="{
<button v-if="permissions.view_all || permissions.manage_resources" @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':
@@ -38,7 +38,7 @@
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="{
<button v-if="permissions.view_all" @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':
@@ -47,26 +47,52 @@
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>
<button v-if="permissions.manager_permissions" @click="activeTab = 'permissions'" :class="{
'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 font-bold':
activeTab === 'permissions',
'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 !== 'permissions',
}"
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('managerPermissions') }}
</button>
</div>
<div class="tab-content">
<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'" />
<WarningReporting v-if="activeTab === 'warning' && permissions.view_all" />
<QrCodeManagement v-if="activeTab === 'qr' && (permissions.view_all || permissions.manage_resources)" />
<PersonnelManagement v-if="activeTab === 'personnel' && (permissions.view_all || permissions.edit_workers)" />
<ManagerPermissions v-if="activeTab === 'permissions' && permissions.manager_permissions" />
<GeofenceManagement v-if="activeTab === 'geofencing' && (permissions.view_all || permissions.manage_resources)" />
<KillSwitchManagement v-if="activeTab === 'killSwitch' && permissions.view_all" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { permissions, fetchPermissions } from '@/stores/permissions.js'
import WarningReporting from '@/components/WarningReporting.vue'
import QrCodeManagement from '@/components/QrCodeManagement.vue'
import PersonnelManagement from '@/components/PersonnelManagement.vue'
import ManagerPermissions from '@/components/ManagerPermissions.vue'
import GeofenceManagement from '@/components/GeofenceManagement.vue'
import KillSwitchManagement from '@/components/KillSwitchManagement.vue'
const activeTab = ref('personnel')
onMounted(async () => {
await fetchPermissions();
// Set the default active tab to the first one the user has permission for
if (permissions.value.view_all) {
activeTab.value = 'personnel'; // Default to personnel tab if view_all is true
} else if (permissions.value.edit_workers) {
activeTab.value = 'personnel';
} else if (permissions.value.manager_permissions) {
activeTab.value = 'permissions';
} else if (permissions.value.manage_resources) {
activeTab.value = 'qr';
}
});
</script>
<style scoped>