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
+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">