feat(考勤管理): 添加手动打卡记录功能和加班计算

- 在考勤记录页面添加手动打卡表单
- 实现后端API处理手动打卡记录
- 新增工人仪表盘显示姓名
- 在考勤报表中添加加班工资计算功能
This commit is contained in:
sudomarcma
2025-06-16 17:17:24 +08:00
parent a9759ac2c4
commit 2b2947c0ce
5 changed files with 692 additions and 55 deletions
+66 -6
View File
@@ -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')
+449 -36
View File
@@ -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 class="action-buttons">
<button @click="generateReport" :disabled="!canGenerate">Generate Report</button>
<button @click="exportReport" :disabled="!canGenerate" class="button-secondary">
Export (CSV)
</button>
</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 Attendance & OT Report
</button>
</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;
+1 -1
View File
@@ -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">
+153 -10
View File
@@ -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>
+23 -2
View File
@@ -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) {