From 2b2947c0ce1040bc48a3154cacd576e993776ebb Mon Sep 17 00:00:00 2001 From: sudomarcma Date: Mon, 16 Jun 2025 17:17:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=80=83=E5=8B=A4=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=89=8B=E5=8A=A8=E6=89=93=E5=8D=A1=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8A=9F=E8=83=BD=E5=92=8C=E5=8A=A0=E7=8F=AD=E8=AE=A1?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在考勤记录页面添加手动打卡表单 - 实现后端API处理手动打卡记录 - 新增工人仪表盘显示姓名 - 在考勤报表中添加加班工资计算功能 --- backend/server.js | 72 +++- src/components/AttendanceReporting.vue | 485 +++++++++++++++++++++++-- src/components/PersonnelManagement.vue | 2 +- src/views/AttendanceRecordView.vue | 163 ++++++++- src/views/WorkerDashboardView.vue | 25 +- 5 files changed, 692 insertions(+), 55 deletions(-) diff --git a/backend/server.js b/backend/server.js index 30ba529..e70c690 100644 --- a/backend/server.js +++ b/backend/server.js @@ -37,8 +37,6 @@ async function startServer() { app.use(cors()) app.use(express.json()) - // Helper functions can be placed here if any are needed in the future - // --- API Endpoints --- // Auth Endpoint @@ -93,6 +91,25 @@ async function startServer() { } }) + // Fetch worker details endpoint + app.get('/api/workers/:id', async (req, res) => { + try { + const { id } = req.params + const [rows] = await db.execute( + 'SELECT full_name FROM workers WHERE id = ? AND role = "worker"', + [id], + ) + if (rows.length > 0) { + res.json({ full_name: rows[0].full_name }) + } else { + res.status(404).json({ message: 'Worker not found.' }) + } + } catch (error) { + console.error('Get worker details error:', error) + res.status(500).json({ message: 'Database error fetching worker details.' }) + } + }) + // Worker Status Endpoint app.get('/api/worker/status/:userId', async (req, res) => { try { @@ -104,7 +121,7 @@ async function startServer() { if (rows.length > 0) { res.json({ eventType: rows[0].event_type }) } else { - res.json({ eventType: 'clock_out' }) + res.json({ eventType: 'clock_out' }) // Default to clocked out } } catch (error) { console.error('Worker status error:', error) @@ -116,8 +133,9 @@ async function startServer() { app.get('/api/worker/clock-history/:userId', async (req, res) => { try { const { userId } = req.params + // MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries const [rows] = await db.execute( - `SELECT cr.id, cr.event_type, cr.timestamp, qc.name as qrCodeUsedName, cr.latitude, cr.longitude FROM clock_records cr JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`, + `SELECT cr.id, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id WHERE cr.worker_id = ? ORDER BY cr.timestamp DESC`, [userId], ) res.json(rows) @@ -186,6 +204,42 @@ async function startServer() { } }) + // --- NEW --- Manager: POST (Add Manual Attendance Record) + // Note: For this to work, you may need to alter your database table: + // ALTER TABLE clock_records ADD COLUMN notes TEXT; + app.post('/api/managers/add-record', async (req, res) => { + try { + const { workerId, eventType, timestamp, notes } = req.body + + if (!workerId || !eventType || !timestamp) { + return res + .status(400) + .json({ message: 'Worker ID, event type, and timestamp are required.' }) + } + + // Check last event to prevent adding a duplicate event type + const [lastEventRows] = await db.execute( + 'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1', + [workerId], + ) + + if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) { + const status = eventType === 'clock_in' ? 'in' : 'out' + return res.status(409).json({ message: `Worker is already clocked ${status}.` }) + } + + await db.execute( + 'INSERT INTO clock_records (worker_id, event_type, timestamp, notes, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, NULL, NULL, NULL)', + [workerId, eventType, timestamp, notes], + ) + + res.status(201).json({ message: 'Manual record added successfully.' }) + } catch (error) { + console.error('Add manual record error:', error) + res.status(500).json({ message: 'Database error adding manual record.' }) + } + }) + // Manager: GET Attendance Records app.get('/api/managers/attendance-records', async (req, res) => { try { @@ -196,7 +250,10 @@ async function startServer() { const idsArray = workerIds.split(',').map(Number) if (idsArray.length === 0) return res.json([]) const placeholders = idsArray.map(() => '?').join(',') - let query = `SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, qc.name as qrCodeUsedName, cr.latitude, cr.longitude FROM clock_records cr JOIN qr_codes qc ON cr.qr_code_id = qc.id JOIN workers w ON cr.worker_id = w.id WHERE cr.worker_id IN (${placeholders})` + + // MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries, and select `notes` + let query = `SELECT cr.id, w.full_name, cr.event_type, cr.timestamp, COALESCE(qc.name, 'Manual Entry') as qrCodeUsedName, cr.latitude, cr.longitude, cr.notes FROM clock_records cr LEFT JOIN qr_codes qc ON cr.qr_code_id = qc.id JOIN workers w ON cr.worker_id = w.id WHERE cr.worker_id IN (${placeholders})` + const params = [...idsArray] if (startDate && endDate) { const endOfDay = new Date(endDate) @@ -205,10 +262,13 @@ async function startServer() { params.push(startDate, endOfDay) } query += ' ORDER BY w.full_name, cr.timestamp DESC' + const [rows] = await db.execute(query, params) + if (format === 'csv') { + // MODIFIED: Add 'notes' to CSV export const json2csvParser = new Parser({ - fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName'], + fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'], }) const csv = json2csvParser.parse(rows) res.header('Content-Type', 'text/csv') diff --git a/src/components/AttendanceReporting.vue b/src/components/AttendanceReporting.vue index 0f71253..0777a88 100644 --- a/src/components/AttendanceReporting.vue +++ b/src/components/AttendanceReporting.vue @@ -1,8 +1,7 @@ @@ -112,12 +216,31 @@ const selectedWorkers = ref([]) const filters = ref({ startDate: '', endDate: '' }) const loadingReport = ref(false) +const reportGenerated = ref(false) const reportData = ref([]) +const overtimeReport = ref(null) + +// --- OT & SALARY STATE --- +const monthlySalary = ref(null) +const overtimeSettings = ref({ + restDayFactor: 2, + publicHolidayFactor: 3, + publicHolidays: [], +}) + +// --- CALENDAR STATE --- +const calendarDate = ref(new Date()) // --- COMPUTED --- -const canGenerate = computed( - () => selectedWorkers.value.length > 0 && filters.value.startDate && filters.value.endDate, -) +const canGenerate = computed(() => { + return ( + selectedWorkers.value.length > 0 && + filters.value.startDate && + filters.value.endDate && + monthlySalary.value && + monthlySalary.value > 0 + ) +}) const groupedReportData = computed(() => { return reportData.value.reduce((groups, record) => { @@ -130,6 +253,39 @@ const groupedReportData = computed(() => { }, {}) }) +const calendarGrid = computed(() => { + const year = calendarDate.value.getFullYear() + const month = calendarDate.value.getMonth() + const firstDayOfMonth = new Date(year, month, 1).getDay() // 0=Sun + const daysInMonth = new Date(year, month + 1, 0).getDate() + const grid = [] + const weekdayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + + for (let i = 0; i < firstDayOfMonth; i++) { + grid.push({ type: 'padding' }) + } + + for (let day = 1; day <= daysInMonth; day++) { + const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart( + 2, + '0', + )}` + grid.push({ + type: 'day', + date: day, + dateString: dateString, + isHoliday: overtimeSettings.value.publicHolidays.includes(dateString), + }) + } + + return { + grid, + weekdayLabels, + monthName: calendarDate.value.toLocaleString('default', { month: 'long' }), + year, + } +}) + // --- METHODS --- const handleSearch = () => { if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) { @@ -180,42 +336,197 @@ const removeWorker = (workerId) => { } const generateReport = async () => { - if (!canGenerate.value) return + if (!canGenerate.value) { + alert('Please select workers, set valid date range, and enter a salary.') + return + } loadingReport.value = true + reportGenerated.value = false reportData.value = [] + overtimeReport.value = null const workerIds = selectedWorkers.value.map((w) => w.id).join(',') const url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}` try { const res = await fetch(url) - if (res.ok) { - reportData.value = await res.json() + if (!res.ok) throw new Error('Failed to fetch attendance records') + + const fetchedRecords = await res.json() + reportData.value = fetchedRecords + + // --- OT CALCULATION FOR UI SUMMARY --- + const otResults = {} + const hourlyRate = monthlySalary.value / 26 / 8 + + for (const worker of selectedWorkers.value) { + const workerRecords = fetchedRecords + .filter((r) => r.full_name === worker.full_name) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) + + const dailyBreakdown = [] + let clockInTime = null + + for (const record of workerRecords) { + if (record.event_type === 'clock_in' && !clockInTime) { + clockInTime = new Date(record.timestamp) + } else if (record.event_type === 'clock_out' && clockInTime) { + const clockOutTime = new Date(record.timestamp) + const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60) + + if (hours > 0) { + const date = clockInTime.toISOString().split('T')[0] + const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date) + const factor = isPublicHoliday + ? overtimeSettings.value.publicHolidayFactor + : overtimeSettings.value.restDayFactor + const otPay = hours * hourlyRate * factor + dailyBreakdown.push({ hours, otPay }) + } + clockInTime = null // Reset for the next session + } + } + + const totalHours = dailyBreakdown.reduce((sum, day) => sum + day.hours, 0) + const totalOtPay = dailyBreakdown.reduce((sum, day) => sum + day.otPay, 0) + otResults[worker.full_name] = { totalHours, totalOtPay } } + overtimeReport.value = otResults + reportGenerated.value = true } catch (err) { console.error('Failed to generate report', err) + alert('An error occurred while generating the report.') } finally { loadingReport.value = false } } -const exportReport = () => { - if (!canGenerate.value) return - const workerIds = selectedWorkers.value.map((w) => w.id).join(',') - const url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}&format=csv` - window.open(url, '_blank') +// --- UPDATED CSV EXPORT METHOD --- +const exportOtSummaryCsv = () => { + if (reportData.value.length === 0) return + + const headers = [ + 'Worker Name', + 'Event', + 'Timestamp', + 'Location', + 'Session Hours', + 'Session OT Wage (RM)', + ] + const allRows = [] + const hourlyRate = monthlySalary.value / 26 / 8 + + for (const worker of selectedWorkers.value) { + const workerRecords = reportData.value + .filter((r) => r.full_name === worker.full_name) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)) + + let clockInRecord = null + let workerTotalWage = 0 + + for (const record of workerRecords) { + const eventType = record.event_type === 'clock_in' ? 'Clock In' : 'Clock Out' + const timestamp = new Date(record.timestamp).toLocaleString() + let row = [ + `"${worker.full_name}"`, + `"${eventType}"`, + `"${timestamp}"`, + `"${record.qrCodeUsedName}"`, + ] + + if (record.event_type === 'clock_in') { + if (!clockInRecord) { + clockInRecord = record + } + row.push('', '') // Push empty cells for hours and wage on clock-in + allRows.push(row.join(',')) + } else if (record.event_type === 'clock_out') { + if (clockInRecord) { + const clockOutTime = new Date(record.timestamp) + const clockInTime = new Date(clockInRecord.timestamp) + const hours = (clockOutTime - clockInTime) / (1000 * 60 * 60) + + if (hours > 0) { + const date = clockInTime.toISOString().split('T')[0] + const isPublicHoliday = overtimeSettings.value.publicHolidays.includes(date) + const factor = isPublicHoliday + ? overtimeSettings.value.publicHolidayFactor + : overtimeSettings.value.restDayFactor + const otPay = hours * hourlyRate * factor + workerTotalWage += otPay // Accumulate wage for summary + + row.push(hours.toFixed(2), otPay.toFixed(2)) + } else { + row.push('0.00', '0.00') + } + clockInRecord = null + } else { + row.push('N/A', 'N/A') + } + allRows.push(row.join(',')) + } + } + + // Add summary row for the worker + const summaryRow = [`"${worker.full_name} Total"`, '', '', '', '', workerTotalWage.toFixed(2)] + allRows.push(summaryRow.join(',')) + + // Add empty row for separation + allRows.push('') + } + + // Remove the last empty row + if (allRows.length > 0) { + allRows.pop() + } + + // --- CHANGED SECTION FOR FILENAME --- + // Create a formatted timestamp for the filename + const now = new Date() + const timestamp = now.toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '-') + const fileName = `${timestamp}_report.csv` // e.g., 2025-06-16_16-46-25_report.csv + // --- END OF CHANGED SECTION --- + + let csvContent = headers.join(',') + '\n' + allRows.join('\n') + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const link = document.createElement('a') + const url = URL.createObjectURL(blob) + link.setAttribute('href', url) + link.setAttribute('download', fileName) + link.style.visibility = 'hidden' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +// --- CALENDAR METHODS --- +const changeMonth = (offset) => { + const newDate = new Date(calendarDate.value) + newDate.setMonth(newDate.getMonth() + offset) + calendarDate.value = newDate +} + +const toggleHoliday = (dateString) => { + const index = overtimeSettings.value.publicHolidays.indexOf(dateString) + if (index === -1) { + overtimeSettings.value.publicHolidays.push(dateString) + } else { + overtimeSettings.value.publicHolidays.splice(index, 1) + } } onMounted(() => { const today = new Date() + filters.value.endDate = today.toISOString().split('T')[0] const sevenDaysAgo = new Date() sevenDaysAgo.setDate(today.getDate() - 7) - filters.value.endDate = today.toISOString().split('T')[0] filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0] + calendarDate.value = new Date(filters.value.startDate + 'T00:00:00') }) diff --git a/src/views/WorkerDashboardView.vue b/src/views/WorkerDashboardView.vue index 7119f1f..891921b 100644 --- a/src/views/WorkerDashboardView.vue +++ b/src/views/WorkerDashboardView.vue @@ -1,5 +1,6 @@