diff --git a/backend/config/db.js b/backend/config/db.js index b78cc30..8e7fae0 100644 --- a/backend/config/db.js +++ b/backend/config/db.js @@ -2,7 +2,7 @@ import 'dotenv/config'; export const APP_TIMEZONE = - process.env.APP_TIMEZONE || 'Asia/Kuala_Lumpur'; // default for Nilai + process.env.APP_TIMEZONE || '+07:00'; // default for Nilai //process.env.APP_TIMEZONE || 'Asia/Jakarta'; // default for Indonesia - // All dates from DB are treated as if they are in this timezone \ No newline at end of file + // All dates from DB are treated as if they are in this timezone diff --git a/backend/managerRoutes.js b/backend/managerRoutes.js index 5c85ae0..ffd0490 100644 --- a/backend/managerRoutes.js +++ b/backend/managerRoutes.js @@ -5,49 +5,49 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import ExcelJS from 'exceljs'; import { APP_TIMEZONE } from './config/db.js'; - -export default function(db) { +import { getConnection } from './pool.js'; +const db = await getConnection(); +export default function() { const router = express.Router(); - router.use((req, _res, next) => { req.tz = APP_TIMEZONE; next(); }); -/* === TZ helpers (no deps) === */ -const _partsToObj = (parts) => parts.reduce((a, p) => (a[p.type] = p.value, a), {}); -const _tzOffsetMinutes = (zone, dUtc) => { - const fmt = new Intl.DateTimeFormat('en-US', { - timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', - hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false - }); - const p = _partsToObj(fmt.formatToParts(dUtc)); - const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second); - return (asUTC - dUtc.getTime()) / 60000; // e.g. +480 for +08:00 -}; -const parseNaiveAsTZ = (s, zone) => { - if (s instanceof Date) return s; - if (typeof s === 'number') return new Date(s); - if (typeof s === 'string' && s.includes('T')) return new Date(s); // ISO with Z/offset - const [d, t = '00:00:00'] = String(s).split(' '); - const [Y, M, D] = d.split('-').map(Number); - const [h, m, sec = 0] = t.split(':').map(Number); - const guessUtc = new Date(Date.UTC(Y, M - 1, D, h, m, sec)); - const off = _tzOffsetMinutes(zone, guessUtc); - return new Date(guessUtc.getTime() - off * 60000); -}; -const ymdInTZ = (date, zone) => - new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); -const hmInTZ = (date, zone) => - new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date); -const hmsInTZ = (date, zone) => - new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date); -const ymdHmsInTZ = (date, zone) => `${ymdInTZ(date, zone)} ${hmsInTZ(date, zone)}`; -const dayNameFromYMD = (yyyyMmDd) => { - const [y, m, dd] = yyyyMmDd.split('-').map(Number); - const d = new Date(y, m - 1, dd, 12, 0, 0, 0); // noon avoids DST edges - return ['SUN','MON','TUE','WED','THU','FRI','SAT'][d.getDay()]; -}; + /* === TZ helpers (no deps) === */ + const _partsToObj = (parts) => parts.reduce((a, p) => (a[p.type] = p.value, a), {}); + const _tzOffsetMinutes = (zone, dUtc) => { + const fmt = new Intl.DateTimeFormat('en-US', { + timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false + }); + const p = _partsToObj(fmt.formatToParts(dUtc)); + const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second); + return (asUTC - dUtc.getTime()) / 60000; // e.g. +480 for +08:00 + }; + const parseNaiveAsTZ = (s, zone) => { + if (s instanceof Date) return s; + if (typeof s === 'number') return new Date(s); + if (typeof s === 'string' && s.includes('T')) return new Date(s); // ISO with Z/offset + const [d, t = '00:00:00'] = String(s).split(' '); + const [Y, M, D] = d.split('-').map(Number); + const [h, m, sec = 0] = t.split(':').map(Number); + const guessUtc = new Date(Date.UTC(Y, M - 1, D, h, m, sec)); + const off = _tzOffsetMinutes(zone, guessUtc); + return new Date(guessUtc.getTime() - off * 60000); + }; + const ymdInTZ = (date, zone) => + new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); + const hmInTZ = (date, zone) => + new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false }).format(date); + const hmsInTZ = (date, zone) => + new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date); + const ymdHmsInTZ = (date, zone) => `${ymdInTZ(date, zone)} ${hmsInTZ(date, zone)}`; + const dayNameFromYMD = (yyyyMmDd) => { + const [y, m, dd] = yyyyMmDd.split('-').map(Number); + const d = new Date(y, m - 1, dd, 12, 0, 0, 0); // noon avoids DST edges + return ['SUN','MON','TUE','WED','THU','FRI','SAT'][d.getDay()]; + }; // Middleware to authenticate and authorize managers @@ -105,7 +105,6 @@ const dayNameFromYMD = (yyyyMmDd) => { // Definitive version using a dedicated database connection router.post('/enabled-dates/update', checkPermission('manage_resources'), async (req, res) => { - let connection; // Define connection here to ensure it's accessible in the 'finally' block try { const { datesToEnable, datesToDisable } = req.body; @@ -113,17 +112,14 @@ const dayNameFromYMD = (yyyyMmDd) => { return res.status(400).json({ message: 'Invalid input format.' }); } - // 1. Get a single, dedicated connection from the pool - connection = await db.getConnection(); - // 2. Process all deletions sequentially on the dedicated connection for (const date of datesToDisable) { - await connection.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]); + await db.execute('DELETE FROM enabled_dates WHERE enabled_date = ?', [date]); } // 3. Process all insertions sequentially on the dedicated connection for (const date of datesToEnable) { - await connection.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]); + await db.execute('INSERT IGNORE INTO enabled_dates (enabled_date) VALUES (?)', [date]); } res.status(200).json({ message: 'Work schedule updated successfully.' }); @@ -132,10 +128,7 @@ const dayNameFromYMD = (yyyyMmDd) => { console.error('Error updating work schedule:', error); res.status(500).json({ message: 'Database error during schedule update.' }); } finally { - // 4. Ensure the dedicated connection is always released back to the pool - if (connection) { - connection.release(); - } + ; } }); // --- END: Date Management Routes --- @@ -161,7 +154,7 @@ const dayNameFromYMD = (yyyyMmDd) => { const query = ` SELECT cr.worker_id, w.full_name, COUNT(*) as count FROM clock_records cr - JOIN workers w ON cr.worker_id = w.id + JOIN workers w ON cr.worker_id = w.id WHERE cr.event_type = 'failed' AND cr.timestamp BETWEEN ? AND ? ${searchQuery} @@ -187,7 +180,7 @@ const dayNameFromYMD = (yyyyMmDd) => { const query = ` SELECT cr.id, cr.timestamp, cr.event_type, COALESCE(qc.name, 'N/A') as qrCodeUsedName, cr.notes FROM clock_records cr - LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? AND cr.event_type = 'failed' AND cr.timestamp BETWEEN ? AND ? @@ -203,61 +196,60 @@ const dayNameFromYMD = (yyyyMmDd) => { } }); - // GET attendance records with a modified query to avoid the MySQL 5.7 bug - router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => { - try { - const { workerIds, startDate, endDate } = req.query; - const TZ = req.tz; + // GET attendance records with a modified query to avoid the MySQL 5.7 bug + router.get('/attendance-records/export-raw', checkPermission('view_all'), async (req, res) => { + try { + const { workerIds, startDate, endDate } = req.query; + const TZ = req.tz; - if (!startDate || !endDate) { - return res.status(400).json({ message: 'Start date and end date are required.' }); - } - - let workerIdClause = ''; - const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]; - - if (workerIds) { - const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); - if (idsArray.length > 0) { - workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + if (!startDate || !endDate) { + return res.status(400).json({ message: 'Start date and end date are required.' }); } + + let workerIdClause = ''; + const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]; + + if (workerIds) { + const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); + if (idsArray.length > 0) { + workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + } + } + + const query = ` + SELECT w.username, w.full_name, cr.event_type, cr.timestamp, + COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} + ORDER BY cr.timestamp DESC + `; + + const [rows] = await db.execute(query, params); + const shaped = rows.map(r => ({ + username: r.username, + full_name: r.full_name, + event_type: r.event_type, + timestamp: r.timestamp, + qr_code_name: r.qr_code_name, + notes: r.notes + })); + + const json2csvParser = new Parser({ + fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] + }); + const csv = json2csvParser.parse(shaped); + + res.set('X-Export-TZ', TZ); + res.header('Content-Type', 'text/csv') + .attachment(`raw_attendance_${startDate}_to_${endDate}.csv`) + .send(csv); + } catch (error) { + console.error('Raw attendance export error:', error); + res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message }); } - - const query = ` - SELECT w.username, w.full_name, cr.event_type, cr.timestamp, - COALESCE(qc.name, 'Manual Entry') as qr_code_name, cr.notes - FROM clock_records cr - JOIN workers w ON cr.worker_id = w.id - LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id - WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} - ORDER BY cr.timestamp DESC - `; - - const [rows] = await db.execute(query, params); - - const shaped = rows.map(r => ({ - username: r.username, - full_name: r.full_name, - event_type: r.event_type, - timestamp: r.timestamp, - qr_code_name: r.qr_code_name, - notes: r.notes - })); - - const json2csvParser = new Parser({ - fields: ['username', 'full_name', 'event_type', 'timestamp', 'qr_code_name', 'notes'] - }); - const csv = json2csvParser.parse(shaped); - - res.set('X-Export-TZ', TZ); - res.header('Content-Type', 'text/csv') - .attachment(`raw_attendance_${startDate}_to_${endDate}.csv`) - .send(csv); - } catch (error) { - console.error('Raw attendance export error:', error); - res.status(500).json({ message: 'Database error exporting raw attendance.', details: error.message }); - } -}); + }); router.post('/add-record', checkPermission('edit_workers'), async (req, res) => { try { @@ -295,184 +287,184 @@ const dayNameFromYMD = (yyyyMmDd) => { } }) - router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => { - try { - const { workerIds, startDate, endDate } = req.query; - const TZ = req.tz; + router.get('/attendance-records/export', checkPermission('view_all'), async (req, res) => { + try { + const { workerIds, startDate, endDate } = req.query; + const TZ = req.tz; - if (!startDate || !endDate) { - return res.status(400).json({ message: 'Start date and end date are required.' }); - } - - const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx'; - - let workerIdClause = ''; - const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]; - - if (workerIds) { - const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); - if (idsArray.length > 0) { - workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + if (!startDate || !endDate) { + return res.status(400).json({ message: 'Start date and end date are required.' }); } - } - const query = ` - SELECT - cr.worker_id, - w.username, - w.full_name, - w.department, - cr.event_type, - cr.timestamp, - COALESCE(qc.name, 'Manual Entry') AS qr_code_name - FROM clock_records cr - JOIN workers w ON cr.worker_id = w.id - LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id - WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} + const wantXlsx = String(req.query.format || 'csv').toLowerCase() === 'xlsx'; + + let workerIdClause = ''; + const params = [`${startDate} 00:00:00`, `${endDate} 23:59:59`]; + + if (workerIds) { + const idsArray = workerIds.split(',').map(Number).filter(id => !isNaN(id)); + if (idsArray.length > 0) { + workerIdClause = `AND cr.worker_id IN (${idsArray.join(',')})`; + } + } + + const query = ` + SELECT + cr.worker_id, + w.username, + w.full_name, + w.department, + cr.event_type, + cr.timestamp, + COALESCE(qc.name, 'Manual Entry') AS qr_code_name + FROM clock_records cr + JOIN workers w ON cr.worker_id = w.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + WHERE cr.timestamp BETWEEN ? AND ? ${workerIdClause} AND cr.event_type IN ('clock_in','clock_out') - ORDER BY cr.worker_id, cr.timestamp ASC - `; + ORDER BY cr.worker_id, cr.timestamp ASC + `; - const [rows] = await db.execute(query, params); + const [rows] = await db.execute(query, params); // ---- Group events by worker/day ---- - const workByDay = {}; - rows.forEach(row => { - const ts = parseNaiveAsTZ(row.timestamp, TZ); - const day = ymdInTZ(ts, TZ); - if (!workByDay[row.worker_id]) { - workByDay[row.worker_id] = { - username: row.username, - full_name: row.full_name, - department: row.department || '', - days: {} - }; - } - if (!workByDay[row.worker_id].days[day]) { - workByDay[row.worker_id].days[day] = []; - } - workByDay[row.worker_id].days[day].push({ - type: row.event_type, - time: ts, - qr_code_name: row.qr_code_name - }); - }); - - // ---- Build rows: one per successful [clock_in, clock_out] session ---- -const csvData = []; -const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows - -for (const workerId in workByDay) { - const w = workByDay[workerId]; - const perWorkerRows = []; - - for (const day of Object.keys(w.days).sort()) { - // events for this day in ascending time - const events = w.days[day].slice().sort((a, b) => a.time - b.time); - - let open = null; - let openQr = 'Manual Entry'; - - for (const e of events) { - if (e.type === 'clock_in' && open == null) { - open = e.time; - openQr = e.qr_code_name || 'Manual Entry'; - } else if (e.type === 'clock_out' && open != null) { - const start = open; - const end = e.time; - - const dailyRow = { - username: w.username, - full_name: w.full_name, - date: day, - day: dayNameFromYMD(day), - clock_in: hmInTZ(start, TZ), - clock_out: hmInTZ(end, TZ), - work_hours: ((end - start) / 3600000).toFixed(2), - qr_code_name: openQr - }; - - csvData.push(dailyRow); - perWorkerRows.push(dailyRow); - - // close the session - open = null; - openQr = 'Manual Entry'; - } - } - } - - byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows); -} - // ===== XLSX branch: grouped header + per-day summary columns ===== - if (wantXlsx) { - const wb = new ExcelJS.Workbook(); - const ws = wb.addWorksheet('Attendance'); - - ws.columns = [ - { header: 'Date', key: 'date', width: 12 }, - { header: 'Day', key: 'day', width: 8 }, - { header: 'Clock In', key: 'clock_in', width: 10 }, - { header: 'Clock Out', key: 'clock_out', width: 10 }, - { header: 'Work Hours', key: 'work_hours', width: 12 }, - { header: 'QR Code', key: 'qr_code_name', width: 24 }, - ]; - - for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) { - const [username, full_name, dept] = key.split('||'); - - if (ws.lastRow) ws.addRow([]); - - // Bold merged group header: "username full_name Dept: X" - const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1; - ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`); - const titleCell = ws.getCell(`A${titleRowIdx}`); - titleCell.value = dept ? `${username} ${full_name} Dept: ${dept}` : `${username} ${full_name}`; - titleCell.font = { bold: true, size: 12 }; - titleCell.alignment = { horizontal: 'left', vertical: 'middle' }; - - // Header row under the group - const hdr = ws.addRow({ - date: 'Date', - day: 'Day', - clock_in: 'Clock In', - clock_out: 'Clock Out', - work_hours: 'Work Hours', - qr_code_name: 'QR Code', + const workByDay = {}; + rows.forEach(row => { + const ts = parseNaiveAsTZ(row.timestamp, TZ); + const day = ymdInTZ(ts, TZ); + if (!workByDay[row.worker_id]) { + workByDay[row.worker_id] = { + username: row.username, + full_name: row.full_name, + department: row.department || '', + days: {} + }; + } + if (!workByDay[row.worker_id].days[day]) { + workByDay[row.worker_id].days[day] = []; + } + workByDay[row.worker_id].days[day].push({ + type: row.event_type, + time: ts, + qr_code_name: row.qr_code_name }); - hdr.font = { bold: true }; + }); - // Detail rows (one per day) - for (const r of rowsForWorker) { - ws.addRow(r); + // ---- Build rows: one per successful [clock_in, clock_out] session ---- + const csvData = []; + const byWorkerForXlsx = new Map(); // key = "username||full_name||department" → daily rows + + for (const workerId in workByDay) { + const w = workByDay[workerId]; + const perWorkerRows = []; + + for (const day of Object.keys(w.days).sort()) { + // events for this day in ascending time + const events = w.days[day].slice().sort((a, b) => a.time - b.time); + + let open = null; + let openQr = 'Manual Entry'; + + for (const e of events) { + if (e.type === 'clock_in' && open == null) { + open = e.time; + openQr = e.qr_code_name || 'Manual Entry'; + } else if (e.type === 'clock_out' && open != null) { + const start = open; + const end = e.time; + + const dailyRow = { + username: w.username, + full_name: w.full_name, + date: day, + day: dayNameFromYMD(day), + clock_in: hmInTZ(start, TZ), + clock_out: hmInTZ(end, TZ), + work_hours: ((end - start) / 3600000).toFixed(2), + qr_code_name: openQr + }; + + csvData.push(dailyRow); + perWorkerRows.push(dailyRow); + + // close the session + open = null; + openQr = 'Manual Entry'; + } + } } + + byWorkerForXlsx.set(`${w.username}||${w.full_name}||${w.department}`, perWorkerRows); + } + // ===== XLSX branch: grouped header + per-day summary columns ===== + if (wantXlsx) { + const wb = new ExcelJS.Workbook(); + const ws = wb.addWorksheet('Attendance'); + + ws.columns = [ + { header: 'Date', key: 'date', width: 12 }, + { header: 'Day', key: 'day', width: 8 }, + { header: 'Clock In', key: 'clock_in', width: 10 }, + { header: 'Clock Out', key: 'clock_out', width: 10 }, + { header: 'Work Hours', key: 'work_hours', width: 12 }, + { header: 'QR Code', key: 'qr_code_name', width: 24 }, + ]; + + for (const [key, rowsForWorker] of byWorkerForXlsx.entries()) { + const [username, full_name, dept] = key.split('||'); + + if (ws.lastRow) ws.addRow([]); + + // Bold merged group header: "username full_name Dept: X" + const titleRowIdx = (ws.lastRow ? ws.lastRow.number : 0) + 1; + ws.mergeCells(`A${titleRowIdx}:F${titleRowIdx}`); + const titleCell = ws.getCell(`A${titleRowIdx}`); + titleCell.value = dept ? `${username} ${full_name} Dept: ${dept}` : `${username} ${full_name}`; + titleCell.font = { bold: true, size: 12 }; + titleCell.alignment = { horizontal: 'left', vertical: 'middle' }; + + // Header row under the group + const hdr = ws.addRow({ + date: 'Date', + day: 'Day', + clock_in: 'Clock In', + clock_out: 'Clock Out', + work_hours: 'Work Hours', + qr_code_name: 'QR Code', + }); + hdr.font = { bold: true }; + + // Detail rows (one per day) + for (const r of rowsForWorker) { + ws.addRow(r); + } + } + + ws.eachRow(row => { row.alignment = { vertical: 'middle' }; }); + + const buf = await wb.xlsx.writeBuffer(); + res.set('X-Export-TZ', TZ); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`); + return res.send(Buffer.from(buf)); } - ws.eachRow(row => { row.alignment = { vertical: 'middle' }; }); - - const buf = await wb.xlsx.writeBuffer(); - res.set('X-Export-TZ', TZ); - res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - res.setHeader('Content-Disposition', `attachment; filename="work_hours_${startDate}_to_${endDate}.xlsx"`); - return res.send(Buffer.from(buf)); - } - - // ===== CSV fallback: one row per day; include identity columns ===== - const json2csvParser = new Parser({ - fields: ['username','full_name','date','day','clock_in','clock_out','work_hours','qr_code_name'] - }); - const csv = json2csvParser.parse(csvData); - res.set('X-Export-TZ', TZ); - res - .header('Content-Type', 'text/csv') - .attachment(`work_hours_${startDate}_to_${endDate}.csv`) - .send(csv); - - } catch (error) { - console.error('Work hours export error:', error); - res.status(500).json({ message: 'Database error exporting work hours.', details: error.message }); - } + // ===== CSV fallback: one row per day; include identity columns ===== + const json2csvParser = new Parser({ + fields: ['username','full_name','date','day','clock_in','clock_out','work_hours','qr_code_name'] }); + const csv = json2csvParser.parse(csvData); + res.set('X-Export-TZ', TZ); + res + .header('Content-Type', 'text/csv') + .attachment(`work_hours_${startDate}_to_${endDate}.csv`) + .send(csv); + + } catch (error) { + console.error('Work hours export error:', error); + res.status(500).json({ message: 'Database error exporting work hours.', details: error.message }); + } + }); router.get('/attendance-records', checkPermission('view_all'), async (req, res) => { try { @@ -497,8 +489,8 @@ for (const workerId in workByDay) { COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr - JOIN workers w ON cr.worker_id = w.id - LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + JOIN workers w ON cr.worker_id = w.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id IN (${inClause})`; // Placeholder is replaced here const params = []; @@ -595,11 +587,11 @@ for (const workerId in workByDay) { 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) + 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]; @@ -667,7 +659,7 @@ for (const workerId in workByDay) { 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 + LEFT JOIN manager_permissions mp ON w.id = mp.manager_id `; let countQuery = `SELECT COUNT(w.id) as totalCount FROM workers w`; @@ -738,25 +730,74 @@ for (const workerId in workByDay) { } }); - // POST (add) a new worker + // POST (add) a new worker (with soft-deleted reactivation) router.post('/workers', checkPermission('edit_workers'), async (req, res) => { try { const { username, password, fullName, department, position, role = 'worker' } = req.body; + if (!username || !password || !fullName) { return res.status(400).json({ message: 'Username, password, and full name are required.' }); } - const hashedPassword = await bcrypt.hash(password, 10); - const [result] = await db.execute( - 'INSERT INTO workers (username, password_hash, full_name, role, department, position, status) VALUES (?, ?, ?, ?, ?, ?, ?)', - [username, hashedPassword, fullName, role, department, position, 'active'] // Default status to 'active' + + // Check for existing worker with this username + const [existingRows] = await db.execute( + 'SELECT id, status, role FROM workers WHERE username = ?', + [username] ); - res.status(201).json({ id: result.insertId, username, fullName, role, department, position, status: 'active' }); + + const hashedPassword = await bcrypt.hash(password, 10); + + if (existingRows.length > 0) { + const existing = existingRows[0]; + if (existing.status === 'deleted' && existing.role === 'worker') { + await db.execute( + ` + UPDATE workers + SET password_hash = ?, full_name = ?, role = ?, department = ?, position = ?, status = 'active' + WHERE id = ? + `, + [hashedPassword, fullName, role, department, position, existing.id] + ); + + return res.status(200).json({ + id: existing.id, + username, + fullName, + role, + department, + position, + status: 'active', + restored: true + }); + } + return res.status(409).json({ message: 'Username already exists.' }); + } + const [result] = await db.execute( + ` + INSERT INTO workers (username, password_hash, full_name, role, department, position, status) + VALUES (?, ?, ?, ?, ?, ?, 'active') + `, + [username, hashedPassword, fullName, role, department, position] + ); + + return 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') { return res.status(409).json({ message: 'Username already exists.' }); } - res.status(500).json({ message: 'Database error adding worker.', details: error.message }); + return res.status(500).json({ + message: 'Database error adding worker.', + details: error.message + }); } }); @@ -794,10 +835,10 @@ for (const workerId in workByDay) { router.put('/workers/:id', checkPermission('edit_workers'), async (req, res) => { try { const { id } = req.params; - const { department, position, status } = req.body; + const { department, position, status, fullName } = req.body; // Basic validation - if (!department && !position && !status) { + if (!department && !position && !status && !fullName) { return res.status(400).json({ message: 'No update information provided.' }); } if (status && !['active', 'inactive'].includes(status)) { @@ -820,6 +861,10 @@ for (const workerId in workByDay) { fieldsToUpdate.push('status = ?'); params.push(status); } + if (fullName) { + fieldsToUpdate.push('full_name = ?'); + params.push(fullName); + } updateQuery += ` ${fieldsToUpdate.join(', ')} WHERE id = ? AND role = 'worker'`; params.push(id); @@ -833,7 +878,10 @@ for (const workerId in workByDay) { 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 }); + res.status(500).json({ + message: 'Database error updating worker details.', + details: error.message + }); } }); diff --git a/backend/pool.js b/backend/pool.js new file mode 100644 index 0000000..ab064b6 --- /dev/null +++ b/backend/pool.js @@ -0,0 +1,34 @@ +import mysql from 'mysql2/promise' +import { APP_TIMEZONE } from './config/db.js' +import dotenv from 'dotenv' +import path from 'path' +import { fileURLToPath } from 'url' + +dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }); + +const db = mysql.createPool({ + host: process.env.DB_HOST, + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME, + port: process.env.DB_PORT, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + // timezone: '+08:00', + dateStrings: true +}); + + +const originalGetConnection = db.getConnection.bind(db); + +db.getConnection = async () => { + const connection = await originalGetConnection(); + // 设置时区 + await connection.execute(`SET time_zone = '${APP_TIMEZONE}'`); + return connection; +}; + +export const getConnection = async () => { + return await db.getConnection() +} diff --git a/backend/server.js b/backend/server.js index 0bdab6f..1650397 100644 --- a/backend/server.js +++ b/backend/server.js @@ -7,31 +7,17 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; -import mysql from 'mysql2/promise'; import managerRoutes from './managerRoutes.js'; import workerRoutes from './workerRoutes.js'; -import { APP_TIMEZONE } from './config/db.js' +import { getConnection } from './pool.js' async function startServer() { dotenv.config({ path: path.join(path.dirname(fileURLToPath(import.meta.url)), '.env') }); const app = express(); - const db = mysql.createPool({ - host: process.env.DB_HOST, - user: process.env.DB_USER, - password: process.env.DB_PASSWORD, - database: process.env.DB_NAME, - port: process.env.DB_PORT, - waitForConnections: true, - connectionLimit: 10, - queueLimit: 0, - timezone: APP_TIMEZONE, - dateStrings: true - }); - try { - const connection = await db.getConnection(); + const connection = await getConnection(); console.log('Database connected successfully!'); connection.release(); } catch (error) { @@ -56,7 +42,7 @@ async function startServer() { }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning'], + allowedHeaders: ['Content-Type', 'Authorization', 'ngrok-skip-browser-warning', 'X-User-Timezone'], //added X-User-Timezone for my development (Edison) exposedHeaders: ['Content-Range', 'X-Content-Range'], }; @@ -79,8 +65,8 @@ async function startServer() { app.get('/time', timeHandler); // public path app.get('/api/time', timeHandler); // also under /api - app.use('/api/managers', managerRoutes(db)); - app.use('/api', workerRoutes(db)); + app.use('/api/managers', managerRoutes()); + app.use('/api', workerRoutes()); const httpPort = process.env.HTTP_PORT || 3000; const httpsPort = process.env.HTTPS_PORT || 3443; diff --git a/backend/workerRoutes.js b/backend/workerRoutes.js index 6d3a1a6..60ea2b5 100644 --- a/backend/workerRoutes.js +++ b/backend/workerRoutes.js @@ -2,6 +2,8 @@ import express from 'express'; import { point, polygon, booleanPointInPolygon, pointToLineDistance } from '@turf/turf'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; +import { getConnection } from './pool.js'; +const db = await getConnection(); async function validateDeviceForUser(userId, deviceUuid, db) { const [userRows] = await db.execute('SELECT device_uuid FROM workers WHERE id = ?', [userId]); @@ -21,7 +23,7 @@ async function isClockingEnabled(conn) { return rows.length > 0; } -export default function(db) { +export default function() { const router = express.Router(); // Set DEVICE_UUID_ENABLED to false to completely disable device UUID checking @@ -96,109 +98,108 @@ export default function(db) { router.use(authenticateJWT); router.post('/clock', async (req, res) => { - // NEW: borrow a connection so we can set session time_zone - const conn = await db.getConnection(); - try { - const { userId, eventType, qrCodeValue, latitude, longitude } = req.body; + // NEW: borrow a connection so we can set session time_zone + try { + const { userId, eventType, qrCodeValue, latitude, longitude } = req.body; - // 1) Kill Switch — now evaluated in the session's local day - const clockingAllowed = await isClockingEnabled(conn); // CHANGED: pass conn - if (!clockingAllowed) { - const note = 'Clock-in/out function is not enabled for today.'; - await conn.execute( // CHANGED: use conn - `INSERT INTO clock_records + // 1) Kill Switch — now evaluated in the session's local day + const clockingAllowed = await isClockingEnabled(db); // CHANGED: pass conn + if (!clockingAllowed) { + const note = 'Clock-in/out function is not enabled for today.'; + await db.execute( // CHANGED: use conn + `INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) - VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`, - [userId, qrCodeValue, latitude, longitude, note] - ); - return res.status(403).json({ message: 'error.clockingDisabled' }); - } - - // 2) Geofence Validation (unchanged logic, just switch db -> conn) - if (latitude != null && longitude != null) { - const [activeFences] = await conn.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED - - if (activeFences.length === 0) { - const note = 'Cannot clock in: No active work area is defined.'; - await conn.execute( // CHANGED - `INSERT INTO clock_records - (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`, [userId, qrCodeValue, latitude, longitude, note] ); - return res.status(403).json({ message: 'error.noActiveGeofence' }); + return res.status(403).json({ message: 'error.clockingDisabled' }); } - const userLocation = point([longitude, latitude]); - const parsedPolygons = []; - let isInside = false; + // 2) Geofence Validation (unchanged logic, just switch db -> conn) + if (latitude != null && longitude != null) { + const [activeFences] = await db.execute('SELECT coordinates FROM geofences WHERE is_active = 1'); // CHANGED - for (const fence of activeFences) { - try { - if (!fence.coordinates) continue; - const coordinates = JSON.parse(fence.coordinates); - const fencePolygon = polygon([coordinates]); - parsedPolygons.push(fencePolygon); - if (booleanPointInPolygon(userLocation, fencePolygon)) { - isInside = true; - break; + if (activeFences.length === 0) { + const note = 'Cannot clock in: No active work area is defined.'; + await db.execute( // CHANGED + `INSERT INTO clock_records + (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) + VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`, + [userId, qrCodeValue, latitude, longitude, note] + ); + return res.status(403).json({ message: 'error.noActiveGeofence' }); + } + + const userLocation = point([longitude, latitude]); + const parsedPolygons = []; + let isInside = false; + + for (const fence of activeFences) { + try { + if (!fence.coordinates) continue; + const coordinates = JSON.parse(fence.coordinates); + const fencePolygon = polygon([coordinates]); + parsedPolygons.push(fencePolygon); + if (booleanPointInPolygon(userLocation, fencePolygon)) { + isInside = true; + break; + } + } catch (e) { + console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e }); } - } catch (e) { - console.error('Could not parse geofence coordinates:', { coordinates: fence.coordinates, error: e }); } - } - if (!isInside) { - let minDistance = Infinity; - for (const p of parsedPolygons) { - const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' }); - if (distance < minDistance) minDistance = distance; - } - const distanceString = minDistance.toFixed(2); - const note = `Outside geofence by ${distanceString}m`; - await conn.execute( // CHANGED - `INSERT INTO clock_records + if (!isInside) { + let minDistance = Infinity; + for (const p of parsedPolygons) { + const distance = pointToLineDistance(userLocation, p.geometry.coordinates[0], { units: 'meters' }); + if (distance < minDistance) minDistance = distance; + } + const distanceString = minDistance.toFixed(2); + const note = `Outside geofence by ${distanceString}m`; + await db.execute( // CHANGED + `INSERT INTO clock_records (worker_id, event_type, qr_code_id, latitude, longitude, notes, timestamp) - VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`, - [userId, qrCodeValue, latitude, longitude, note] - ); - return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` }); + VALUES (?, "failed", ?, ?, ?, ?, CURRENT_TIME())`, + [userId, qrCodeValue, latitude, longitude, note] + ); + return res.status(403).json({ message: `error.outsideGeofence|${distanceString}` }); + } } - } - // 3) QR Code and Status Validation (switch db -> conn; logic unchanged) - if (qrCodeValue !== 'FORCE_CLOCK_OUT') { - const [qrRows] = await conn.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED - if (qrRows.length === 0 || !qrRows[0].is_active) { - return res.status(400).json({ message: 'error.invalidQrCode' }); + // 3) QR Code and Status Validation (switch db -> conn; logic unchanged) + if (qrCodeValue !== 'FORCE_CLOCK_OUT') { + const [qrRows] = await db.execute('SELECT is_active FROM qr_codes WHERE id = ?', [qrCodeValue]); // CHANGED + if (qrRows.length === 0 || !qrRows[0].is_active) { + return res.status(400).json({ message: 'error.invalidQrCode' }); + } } + + const [lastEvent] = await db.execute( // CHANGED + 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', + [userId] + ); + if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) { + const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut'; + return res.status(400).json({ message: errorKey }); + } + + // 4) Record Successful Event — store UTC via SQL conversion (no JS date math) + await db.execute( + `INSERT INTO clock_records + (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) + VALUES (?, ?, ?, ?, ?, CURRENT_TIME())`, + [userId, eventType, qrCodeValue, latitude, longitude] + ); + + res.status(201).json({ message: 'Clock event recorded.' }); + } catch (error) { + console.error('!!! CRITICAL ERROR in /clock route !!!:', error); + res.status(500).json({ message: 'error.criticalServer' }); + } finally { + ; } - - const [lastEvent] = await conn.execute( // CHANGED - 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', - [userId] - ); - if (lastEvent.length > 0 && lastEvent[0].event_type === eventType) { - const errorKey = eventType === 'clock_in' ? 'error.alreadyClockedIn' : 'error.alreadyClockedOut'; - return res.status(400).json({ message: errorKey }); - } - - // 4) Record Successful Event — store UTC via SQL conversion (no JS date math) - await conn.execute( - `INSERT INTO clock_records - (worker_id, event_type, qr_code_id, latitude, longitude, timestamp) - VALUES (?, ?, ?, ?, ?, CURRENT_TIME())`, - [userId, eventType, qrCodeValue, latitude, longitude] - ); - - res.status(201).json({ message: 'Clock event recorded.' }); - } catch (error) { - console.error('!!! CRITICAL ERROR in /clock route !!!:', error); - res.status(500).json({ message: 'error.criticalServer' }); - } finally { - if (conn) conn.release(); - } -}); + }); router.get('/workers/:id', async (req, res) => { const { id } = req.params; @@ -220,7 +221,7 @@ export default function(db) { const [rows] = await db.execute(` SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName FROM clock_records cr - LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id + LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC `, [userId]); res.json(rows);