feat(考勤管理): 添加手动打卡记录功能和加班计算
- 在考勤记录页面添加手动打卡表单 - 实现后端API处理手动打卡记录 - 新增工人仪表盘显示姓名 - 在考勤报表中添加加班工资计算功能
This commit is contained in:
+66
-6
@@ -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')
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<template>
|
||||
<div class="attendance-reporting-layout">
|
||||
<!-- Left Panel: Worker Selection -->
|
||||
<div class="selection-panel">
|
||||
<section class="card">
|
||||
<section class="card" style="height: 70vh; display: flex; flex-direction: column">
|
||||
<h3 class="panel-header">1. Select Workers</h3>
|
||||
<div class="search-box">
|
||||
<input
|
||||
@@ -28,9 +27,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-workers-list">
|
||||
<div
|
||||
class="selected-workers-list"
|
||||
style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden"
|
||||
>
|
||||
<h4>Selected for Report ({{ selectedWorkers.length }})</h4>
|
||||
<ul v-if="selectedWorkers.length > 0">
|
||||
<ul v-if="selectedWorkers.length > 0" style="flex-grow: 1; overflow-y: auto">
|
||||
<li v-for="worker in selectedWorkers" :key="worker.id">
|
||||
<span>{{ worker.full_name }}</span>
|
||||
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
|
||||
@@ -41,10 +43,9 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel: Filters & Results -->
|
||||
<div class="results-panel">
|
||||
<section class="card">
|
||||
<h3 class="panel-header">2. Set Filters & Generate</h3>
|
||||
<h3 class="panel-header">2. Report Settings</h3>
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="start-date">Start Date</label>
|
||||
@@ -54,17 +55,118 @@
|
||||
<label for="end-date">End Date</label>
|
||||
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overtime-settings-section" v-if="selectedWorkers.length > 0">
|
||||
<h4 class="settings-header">Overtime Settings</h4>
|
||||
<div class="worker-salaries">
|
||||
<h5>Monthly Salary (RM)</h5>
|
||||
<p class="subtitle">Enter the monthly salary to be applied to all selected workers.</p>
|
||||
<div class="form-group">
|
||||
<input
|
||||
id="monthly-salary"
|
||||
type="number"
|
||||
v-model.number="monthlySalary"
|
||||
class="form-input"
|
||||
placeholder="e.g., 3000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="factors-and-holidays">
|
||||
<div class="factor-input">
|
||||
<h5>OT Factors</h5>
|
||||
<div class="form-group">
|
||||
<label for="rest-day-factor">Rest Day OT Factor</label>
|
||||
<input
|
||||
id="rest-day-factor"
|
||||
type="number"
|
||||
v-model.number="overtimeSettings.restDayFactor"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="holiday-factor">Holiday Day OT Factor</label>
|
||||
<input
|
||||
id="holiday-factor"
|
||||
type="number"
|
||||
v-model.number="overtimeSettings.publicHolidayFactor"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="holiday-picker">
|
||||
<h5>Select Public Holidays</h5>
|
||||
<div class="calendar">
|
||||
<div class="calendar-header">
|
||||
<button @click="changeMonth(-1)">‹</button>
|
||||
<span>{{ calendarGrid.monthName }} {{ calendarGrid.year }}</span>
|
||||
<button @click="changeMonth(1)">›</button>
|
||||
</div>
|
||||
<div class="calendar-weekdays">
|
||||
<div v-for="day in calendarGrid.weekdayLabels" :key="day" class="weekday">
|
||||
{{ day }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-days">
|
||||
<div
|
||||
v-for="(day, index) in calendarGrid.grid"
|
||||
:key="index"
|
||||
class="day-cell"
|
||||
:class="{
|
||||
padding: day.type === 'padding',
|
||||
holiday: day.isHoliday,
|
||||
}"
|
||||
@click="day.type === 'day' && toggleHoliday(day.dateString)"
|
||||
>
|
||||
<span v-if="day.type === 'day'">{{ day.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button @click="generateReport" :disabled="!canGenerate">Generate Report</button>
|
||||
<button @click="exportReport" :disabled="!canGenerate" class="button-secondary">
|
||||
Export (CSV)
|
||||
<button @click="generateReport" :disabled="!canGenerate">
|
||||
Generate Attendance & OT Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top: 2rem" v-if="reportGenerated">
|
||||
<div class="results-display">
|
||||
<h4>Report Results</h4>
|
||||
<div v-if="loadingReport" class="loading-state">Loading...</div>
|
||||
<div v-else-if="reportData.length > 0">
|
||||
<div v-if="overtimeReport" class="overtime-report">
|
||||
<div class="report-header">
|
||||
<h4>Overtime Pay Summary</h4>
|
||||
<button
|
||||
@click="exportOtSummaryCsv"
|
||||
:disabled="!overtimeReport"
|
||||
class="button-primary"
|
||||
>
|
||||
Export OT Summary (CSV)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Total Hours Worked</th>
|
||||
<th>Total OT Pay (RM)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(report, name) in overtimeReport" :key="name">
|
||||
<td>{{ name }}</td>
|
||||
<td>{{ report.totalHours.toFixed(2) }}</td>
|
||||
<td>{{ report.totalOtPay.toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="raw-logs" v-if="reportData.length > 0">
|
||||
<h4 style="margin-top: 2rem">Raw Attendance Data</h4>
|
||||
<div
|
||||
v-for="(group, workerName) in groupedReportData"
|
||||
:key="workerName"
|
||||
@@ -93,9 +195,11 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty-state">Generate a report to see results.</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card" style="margin-top: 2rem; text-align: center" v-if="loadingReport">
|
||||
<p>Loading Report...</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* --- Existing Styles --- */
|
||||
.attendance-reporting-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 350px 1fr;
|
||||
@@ -260,8 +571,6 @@ onMounted(() => {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.selected-workers-list li {
|
||||
display: flex;
|
||||
@@ -286,25 +595,129 @@ onMounted(() => {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* --- Refactored & New Styles --- */
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.action-buttons {
|
||||
margin-left: auto;
|
||||
.overtime-settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.settings-header {
|
||||
margin: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--c-border-light);
|
||||
}
|
||||
.worker-salaries h5,
|
||||
.factor-input h5,
|
||||
.holiday-picker h5 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--c-text-secondary);
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.factors-and-holidays {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.factor-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.action-buttons button {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* --- Calendar --- */
|
||||
.calendar {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.calendar-header button {
|
||||
background: none;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.calendar-weekdays,
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
text-align: center;
|
||||
}
|
||||
.weekday {
|
||||
font-weight: 500;
|
||||
color: var(--c-text-secondary);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.day-cell {
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.day-cell:not(.padding):hover {
|
||||
background-color: var(--c-bg-secondary);
|
||||
}
|
||||
.day-cell.holiday {
|
||||
background-color: var(--c-primary);
|
||||
color: var(--c-primary-text);
|
||||
font-weight: bold;
|
||||
}
|
||||
.day-cell.padding {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* --- Results --- */
|
||||
.results-display .report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.results-display h4 {
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding-bottom: 0.75rem;
|
||||
margin: 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.overtime-report table {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.worker-group {
|
||||
margin-top: 2rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.worker-group-header {
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
id="password"
|
||||
v-model="newWorker.password"
|
||||
class="form-input"
|
||||
placeholder="Create a temporary password"
|
||||
placeholder="e.g., 123456"
|
||||
/>
|
||||
</div>
|
||||
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
|
||||
|
||||
@@ -3,8 +3,38 @@
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<router-link to="/manager/dashboard" class="back-link">← Back to Dashboard</router-link>
|
||||
<h2 class="card-header">Attendance Log for {{ workerName }} (考勤记录管理)</h2>
|
||||
<h2 class="card-header">Attendance Log for {{ workerName }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="manual-entry-card">
|
||||
<h3 class="manual-entry-header">Add Manual Clock-Out</h3>
|
||||
<p class="manual-entry-desc">
|
||||
Use this form if the worker forgot to clock out. The last event must be a clock-in.
|
||||
</p>
|
||||
<div class="manual-entry-form">
|
||||
<div class="form-group">
|
||||
<label for="manual-timestamp">Clock-Out Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
id="manual-timestamp"
|
||||
v-model="manualClockOut.timestamp"
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" style="flex-grow: 1">
|
||||
<label for="manual-notes">Reason (e.g., "Forgot to clock out")</label>
|
||||
<input
|
||||
type="text"
|
||||
id="manual-notes"
|
||||
v-model="manualClockOut.notes"
|
||||
class="form-input"
|
||||
placeholder="Enter a brief note"
|
||||
/>
|
||||
</div>
|
||||
<button @click="addManualClockOut" class="button-primary">Add Record</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<div class="form-group">
|
||||
<label for="start-date">Start Date</label>
|
||||
@@ -16,6 +46,7 @@
|
||||
</div>
|
||||
<button @click="fetchRecords" class="button-primary">Filter Records</button>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -23,11 +54,12 @@
|
||||
<th>Timestamp</th>
|
||||
<th>Location Name</th>
|
||||
<th>Coordinates</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!records.length">
|
||||
<td colspan="4" style="text-align: center; padding: 2rem">
|
||||
<td colspan="5" style="text-align: center; padding: 2rem">
|
||||
No records found for this period.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -40,12 +72,18 @@
|
||||
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
|
||||
<td>{{ record.qrCodeUsedName }}</td>
|
||||
<td>
|
||||
{{
|
||||
record.latitude
|
||||
? `${Number(record.latitude).toFixed(4)}, ${Number(record.longitude).toFixed(4)}`
|
||||
: 'N/A'
|
||||
}}
|
||||
<a
|
||||
v-if="record.latitude && record.longitude"
|
||||
:href="`https://www.google.com/maps?q=${record.latitude},${record.longitude}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="map-link"
|
||||
>
|
||||
Show on map
|
||||
</a>
|
||||
<span v-else>N/A</span>
|
||||
</td>
|
||||
<td>{{ record.notes || 'N/A' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -62,7 +100,20 @@ const records = ref([])
|
||||
const workerName = ref('')
|
||||
const workerId = route.params.workerId
|
||||
|
||||
// 设置默认日期范围为过去7天
|
||||
// Get the current date and time in the required format for datetime-local input
|
||||
const toLocalISOString = (date) => {
|
||||
const tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds
|
||||
const localISOTime = new Date(date - tzoffset).toISOString().slice(0, 16)
|
||||
return localISOTime
|
||||
}
|
||||
|
||||
// New state for manual clock-out form
|
||||
const manualClockOut = ref({
|
||||
timestamp: toLocalISOString(new Date()),
|
||||
notes: '',
|
||||
})
|
||||
|
||||
// Set default date range for filters to the past 7 days
|
||||
const today = new Date()
|
||||
const sevenDaysAgo = new Date(today)
|
||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||
@@ -73,16 +124,31 @@ const filters = ref({
|
||||
})
|
||||
|
||||
const fetchRecords = async () => {
|
||||
// Ensure we have a worker name, fetch if not
|
||||
if (!workerName.value) {
|
||||
try {
|
||||
const workerRes = await fetch(`http://localhost:3000/api/managers/worker/${workerId}`)
|
||||
if (workerRes.ok) {
|
||||
const workerData = await workerRes.json()
|
||||
workerName.value = workerData.full_name
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch worker name:', err)
|
||||
workerName.value = `Worker #${workerId}` // Fallback name
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attendance records
|
||||
let url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerId}`
|
||||
if (filters.value.startDate && filters.value.endDate) {
|
||||
url += `&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
|
||||
console.log(url)
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
records.value = await res.json()
|
||||
if (records.value.length > 0) {
|
||||
// If the worker name hasn't been set yet and records are found, set it.
|
||||
if (!workerName.value && records.value.length > 0) {
|
||||
workerName.value = records.value[0].full_name
|
||||
}
|
||||
}
|
||||
@@ -91,6 +157,48 @@ const fetchRecords = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// New method to add a manual clock-out record
|
||||
const addManualClockOut = async () => {
|
||||
if (!manualClockOut.value.timestamp) {
|
||||
alert('Please select a timestamp for the clock-out.')
|
||||
return
|
||||
}
|
||||
if (!manualClockOut.value.notes) {
|
||||
alert('Please provide a reason/note for the manual entry.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('http://localhost:3000/api/managers/add-record', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
workerId: workerId,
|
||||
eventType: 'clock_out',
|
||||
timestamp: manualClockOut.value.timestamp,
|
||||
notes: manualClockOut.value.notes,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
alert('Manual clock-out recorded successfully!')
|
||||
// Reset form and refresh the records
|
||||
manualClockOut.value.notes = ''
|
||||
manualClockOut.value.timestamp = toLocalISOString(new Date())
|
||||
fetchRecords()
|
||||
} else {
|
||||
alert(`Failed to add record: ${data.message}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to submit manual clock-out:', err)
|
||||
alert('An error occurred while submitting the record.')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
@@ -112,11 +220,37 @@ onMounted(() => {
|
||||
.card-header {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
/* New styles for manual entry card */
|
||||
.manual-entry-card {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.manual-entry-header {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.manual-entry-desc {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.manual-entry-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
@@ -136,4 +270,13 @@ onMounted(() => {
|
||||
.event-type.clock_out {
|
||||
background-color: var(--c-danger);
|
||||
}
|
||||
/* Style for the new map link */
|
||||
.map-link {
|
||||
color: var(--c-primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.map-link:hover {
|
||||
color: var(--c-primary-dark);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="worker-dashboard">
|
||||
<h1>{{ workerName }}</h1>
|
||||
<div class="status-card" :class="isClockedIn ? 'clocked-in' : 'clocked-out'">
|
||||
<div class="status-icon">
|
||||
<span v-if="isClockedIn">✔️</span>
|
||||
@@ -46,13 +47,25 @@ const isClockedIn = ref(false)
|
||||
const isScannerActive = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const workerName = ref('')
|
||||
|
||||
// --- ALIGNMENT CHANGE ---
|
||||
// Get userId from session storage. Redirect if not found.
|
||||
const userId = sessionStorage.getItem('userId')
|
||||
|
||||
const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked Out'))
|
||||
|
||||
//fetch worker name
|
||||
const fetchWorkerDetails = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/workers/${userId}`)
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
workerName.value = data.full_name
|
||||
} catch {
|
||||
errorMessage.value = 'Could not load worker information.'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCurrentStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/worker/status/${userId}`)
|
||||
@@ -69,6 +82,8 @@ onMounted(() => {
|
||||
router.push('/') // Redirect to login if no userId
|
||||
return
|
||||
}
|
||||
// Fetch worker details and current status when component mounts
|
||||
fetchWorkerDetails()
|
||||
fetchCurrentStatus()
|
||||
})
|
||||
|
||||
@@ -84,7 +99,13 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
||||
const response = await fetch('http://localhost:3000/api/clock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: userId, eventType, qrCodeValue, latitude, longitude }),
|
||||
body: JSON.stringify({
|
||||
userId: userId,
|
||||
eventType,
|
||||
qrCodeValue,
|
||||
latitude,
|
||||
longitude,
|
||||
}),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user