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:
@@ -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-----
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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-----
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -313,5 +524,5 @@ const exportWorkHours = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkers();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user