feat(考勤管理): 添加手动打卡记录功能和加班计算
- 在考勤记录页面添加手动打卡表单 - 实现后端API处理手动打卡记录 - 新增工人仪表盘显示姓名 - 在考勤报表中添加加班工资计算功能
This commit is contained in:
+66
-6
@@ -37,8 +37,6 @@ async function startServer() {
|
|||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
// Helper functions can be placed here if any are needed in the future
|
|
||||||
|
|
||||||
// --- API Endpoints ---
|
// --- API Endpoints ---
|
||||||
|
|
||||||
// Auth Endpoint
|
// 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
|
// Worker Status Endpoint
|
||||||
app.get('/api/worker/status/:userId', async (req, res) => {
|
app.get('/api/worker/status/:userId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +121,7 @@ async function startServer() {
|
|||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
res.json({ eventType: rows[0].event_type })
|
res.json({ eventType: rows[0].event_type })
|
||||||
} else {
|
} else {
|
||||||
res.json({ eventType: 'clock_out' })
|
res.json({ eventType: 'clock_out' }) // Default to clocked out
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Worker status error:', error)
|
console.error('Worker status error:', error)
|
||||||
@@ -116,8 +133,9 @@ async function startServer() {
|
|||||||
app.get('/api/worker/clock-history/:userId', async (req, res) => {
|
app.get('/api/worker/clock-history/:userId', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { userId } = req.params
|
const { userId } = req.params
|
||||||
|
// MODIFIED: Use LEFT JOIN and COALESCE to handle manual entries
|
||||||
const [rows] = await db.execute(
|
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],
|
[userId],
|
||||||
)
|
)
|
||||||
res.json(rows)
|
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
|
// Manager: GET Attendance Records
|
||||||
app.get('/api/managers/attendance-records', async (req, res) => {
|
app.get('/api/managers/attendance-records', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -196,7 +250,10 @@ async function startServer() {
|
|||||||
const idsArray = workerIds.split(',').map(Number)
|
const idsArray = workerIds.split(',').map(Number)
|
||||||
if (idsArray.length === 0) return res.json([])
|
if (idsArray.length === 0) return res.json([])
|
||||||
const placeholders = idsArray.map(() => '?').join(',')
|
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]
|
const params = [...idsArray]
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
const endOfDay = new Date(endDate)
|
const endOfDay = new Date(endDate)
|
||||||
@@ -205,10 +262,13 @@ async function startServer() {
|
|||||||
params.push(startDate, endOfDay)
|
params.push(startDate, endOfDay)
|
||||||
}
|
}
|
||||||
query += ' ORDER BY w.full_name, cr.timestamp DESC'
|
query += ' ORDER BY w.full_name, cr.timestamp DESC'
|
||||||
|
|
||||||
const [rows] = await db.execute(query, params)
|
const [rows] = await db.execute(query, params)
|
||||||
|
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
|
// MODIFIED: Add 'notes' to CSV export
|
||||||
const json2csvParser = new Parser({
|
const json2csvParser = new Parser({
|
||||||
fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName'],
|
fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName', 'notes'],
|
||||||
})
|
})
|
||||||
const csv = json2csvParser.parse(rows)
|
const csv = json2csvParser.parse(rows)
|
||||||
res.header('Content-Type', 'text/csv')
|
res.header('Content-Type', 'text/csv')
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="attendance-reporting-layout">
|
<div class="attendance-reporting-layout">
|
||||||
<!-- Left Panel: Worker Selection -->
|
|
||||||
<div class="selection-panel">
|
<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>
|
<h3 class="panel-header">1. Select Workers</h3>
|
||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<input
|
<input
|
||||||
@@ -28,9 +27,12 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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">
|
<li v-for="worker in selectedWorkers" :key="worker.id">
|
||||||
<span>{{ worker.full_name }}</span>
|
<span>{{ worker.full_name }}</span>
|
||||||
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
|
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
|
||||||
@@ -41,10 +43,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Panel: Filters & Results -->
|
|
||||||
<div class="results-panel">
|
<div class="results-panel">
|
||||||
<section class="card">
|
<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="filters">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="start-date">Start Date</label>
|
<label for="start-date">Start Date</label>
|
||||||
@@ -54,17 +55,118 @@
|
|||||||
<label for="end-date">End Date</label>
|
<label for="end-date">End Date</label>
|
||||||
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
|
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="action-buttons">
|
</div>
|
||||||
<button @click="generateReport" :disabled="!canGenerate">Generate Report</button>
|
|
||||||
<button @click="exportReport" :disabled="!canGenerate" class="button-secondary">
|
<div class="overtime-settings-section" v-if="selectedWorkers.length > 0">
|
||||||
Export (CSV)
|
<h4 class="settings-header">Overtime Settings</h4>
|
||||||
</button>
|
<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>
|
</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">
|
<div class="results-display">
|
||||||
<h4>Report Results</h4>
|
<div v-if="overtimeReport" class="overtime-report">
|
||||||
<div v-if="loadingReport" class="loading-state">Loading...</div>
|
<div class="report-header">
|
||||||
<div v-else-if="reportData.length > 0">
|
<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
|
<div
|
||||||
v-for="(group, workerName) in groupedReportData"
|
v-for="(group, workerName) in groupedReportData"
|
||||||
:key="workerName"
|
:key="workerName"
|
||||||
@@ -93,9 +195,11 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="empty-state">Generate a report to see results.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="card" style="margin-top: 2rem; text-align: center" v-if="loadingReport">
|
||||||
|
<p>Loading Report...</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -112,12 +216,31 @@ const selectedWorkers = ref([])
|
|||||||
const filters = ref({ startDate: '', endDate: '' })
|
const filters = ref({ startDate: '', endDate: '' })
|
||||||
|
|
||||||
const loadingReport = ref(false)
|
const loadingReport = ref(false)
|
||||||
|
const reportGenerated = ref(false)
|
||||||
const reportData = ref([])
|
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 ---
|
// --- COMPUTED ---
|
||||||
const canGenerate = computed(
|
const canGenerate = computed(() => {
|
||||||
() => selectedWorkers.value.length > 0 && filters.value.startDate && filters.value.endDate,
|
return (
|
||||||
)
|
selectedWorkers.value.length > 0 &&
|
||||||
|
filters.value.startDate &&
|
||||||
|
filters.value.endDate &&
|
||||||
|
monthlySalary.value &&
|
||||||
|
monthlySalary.value > 0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const groupedReportData = computed(() => {
|
const groupedReportData = computed(() => {
|
||||||
return reportData.value.reduce((groups, record) => {
|
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 ---
|
// --- METHODS ---
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
|
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
|
||||||
@@ -180,42 +336,197 @@ const removeWorker = (workerId) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generateReport = async () => {
|
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
|
loadingReport.value = true
|
||||||
|
reportGenerated.value = false
|
||||||
reportData.value = []
|
reportData.value = []
|
||||||
|
overtimeReport.value = null
|
||||||
|
|
||||||
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
|
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}`
|
const url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerIds}&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (res.ok) {
|
if (!res.ok) throw new Error('Failed to fetch attendance records')
|
||||||
reportData.value = await res.json()
|
|
||||||
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to generate report', err)
|
console.error('Failed to generate report', err)
|
||||||
|
alert('An error occurred while generating the report.')
|
||||||
} finally {
|
} finally {
|
||||||
loadingReport.value = false
|
loadingReport.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportReport = () => {
|
// --- UPDATED CSV EXPORT METHOD ---
|
||||||
if (!canGenerate.value) return
|
const exportOtSummaryCsv = () => {
|
||||||
const workerIds = selectedWorkers.value.map((w) => w.id).join(',')
|
if (reportData.value.length === 0) return
|
||||||
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')
|
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(() => {
|
onMounted(() => {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
|
filters.value.endDate = today.toISOString().split('T')[0]
|
||||||
const sevenDaysAgo = new Date()
|
const sevenDaysAgo = new Date()
|
||||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||||
filters.value.endDate = today.toISOString().split('T')[0]
|
|
||||||
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
|
filters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
|
||||||
|
calendarDate.value = new Date(filters.value.startDate + 'T00:00:00')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* --- Existing Styles --- */
|
||||||
.attendance-reporting-layout {
|
.attendance-reporting-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 350px 1fr;
|
grid-template-columns: 350px 1fr;
|
||||||
@@ -260,8 +571,6 @@ onMounted(() => {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
.selected-workers-list li {
|
.selected-workers-list li {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -286,25 +595,129 @@ onMounted(() => {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Refactored & New Styles --- */
|
||||||
.filters {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-end;
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
.action-buttons {
|
.overtime-settings-section {
|
||||||
margin-left: auto;
|
|
||||||
display: flex;
|
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;
|
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 {
|
.results-display h4 {
|
||||||
border-bottom: 1px solid var(--c-border);
|
margin: 0;
|
||||||
padding-bottom: 0.75rem;
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
.overtime-report table {
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
.worker-group {
|
.worker-group {
|
||||||
margin-top: 2rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
.worker-group-header {
|
.worker-group-header {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
id="password"
|
id="password"
|
||||||
v-model="newWorker.password"
|
v-model="newWorker.password"
|
||||||
class="form-input"
|
class="form-input"
|
||||||
placeholder="Create a temporary password"
|
placeholder="e.g., 123456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
|
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
|
||||||
|
|||||||
@@ -3,8 +3,38 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<router-link to="/manager/dashboard" class="back-link">← Back to Dashboard</router-link>
|
<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>
|
||||||
|
|
||||||
|
<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="filters">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="start-date">Start Date</label>
|
<label for="start-date">Start Date</label>
|
||||||
@@ -16,6 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button @click="fetchRecords" class="button-primary">Filter Records</button>
|
<button @click="fetchRecords" class="button-primary">Filter Records</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -23,11 +54,12 @@
|
|||||||
<th>Timestamp</th>
|
<th>Timestamp</th>
|
||||||
<th>Location Name</th>
|
<th>Location Name</th>
|
||||||
<th>Coordinates</th>
|
<th>Coordinates</th>
|
||||||
|
<th>Notes</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!records.length">
|
<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.
|
No records found for this period.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -40,12 +72,18 @@
|
|||||||
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
|
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
|
||||||
<td>{{ record.qrCodeUsedName }}</td>
|
<td>{{ record.qrCodeUsedName }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{
|
<a
|
||||||
record.latitude
|
v-if="record.latitude && record.longitude"
|
||||||
? `${Number(record.latitude).toFixed(4)}, ${Number(record.longitude).toFixed(4)}`
|
:href="`https://www.google.com/maps?q=${record.latitude},${record.longitude}`"
|
||||||
: 'N/A'
|
target="_blank"
|
||||||
}}
|
rel="noopener noreferrer"
|
||||||
|
class="map-link"
|
||||||
|
>
|
||||||
|
Show on map
|
||||||
|
</a>
|
||||||
|
<span v-else>N/A</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ record.notes || 'N/A' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -62,7 +100,20 @@ const records = ref([])
|
|||||||
const workerName = ref('')
|
const workerName = ref('')
|
||||||
const workerId = route.params.workerId
|
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 today = new Date()
|
||||||
const sevenDaysAgo = new Date(today)
|
const sevenDaysAgo = new Date(today)
|
||||||
sevenDaysAgo.setDate(today.getDate() - 7)
|
sevenDaysAgo.setDate(today.getDate() - 7)
|
||||||
@@ -73,16 +124,31 @@ const filters = ref({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fetchRecords = async () => {
|
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}`
|
let url = `http://localhost:3000/api/managers/attendance-records?workerIds=${workerId}`
|
||||||
if (filters.value.startDate && filters.value.endDate) {
|
if (filters.value.startDate && filters.value.endDate) {
|
||||||
url += `&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
|
url += `&startDate=${filters.value.startDate}&endDate=${filters.value.endDate}`
|
||||||
console.log(url)
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const res = await fetch(url)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
records.value = await res.json()
|
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
|
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(() => {
|
onMounted(() => {
|
||||||
fetchRecords()
|
fetchRecords()
|
||||||
})
|
})
|
||||||
@@ -112,11 +220,37 @@ onMounted(() => {
|
|||||||
.card-header {
|
.card-header {
|
||||||
margin-top: 0.5rem;
|
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 {
|
.filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
}
|
}
|
||||||
.form-group {
|
.form-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -136,4 +270,13 @@ onMounted(() => {
|
|||||||
.event-type.clock_out {
|
.event-type.clock_out {
|
||||||
background-color: var(--c-danger);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="worker-dashboard">
|
<div class="worker-dashboard">
|
||||||
|
<h1>{{ workerName }}</h1>
|
||||||
<div class="status-card" :class="isClockedIn ? 'clocked-in' : 'clocked-out'">
|
<div class="status-card" :class="isClockedIn ? 'clocked-in' : 'clocked-out'">
|
||||||
<div class="status-icon">
|
<div class="status-icon">
|
||||||
<span v-if="isClockedIn">✔️</span>
|
<span v-if="isClockedIn">✔️</span>
|
||||||
@@ -46,13 +47,25 @@ const isClockedIn = ref(false)
|
|||||||
const isScannerActive = ref(false)
|
const isScannerActive = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const successMessage = ref('')
|
const successMessage = ref('')
|
||||||
|
const workerName = ref('')
|
||||||
|
|
||||||
// --- ALIGNMENT CHANGE ---
|
|
||||||
// Get userId from session storage. Redirect if not found.
|
// Get userId from session storage. Redirect if not found.
|
||||||
const userId = sessionStorage.getItem('userId')
|
const userId = sessionStorage.getItem('userId')
|
||||||
|
|
||||||
const clockStatus = computed(() => (isClockedIn.value ? 'Clocked In' : 'Clocked Out'))
|
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 () => {
|
const fetchCurrentStatus = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost:3000/api/worker/status/${userId}`)
|
const response = await fetch(`http://localhost:3000/api/worker/status/${userId}`)
|
||||||
@@ -69,6 +82,8 @@ onMounted(() => {
|
|||||||
router.push('/') // Redirect to login if no userId
|
router.push('/') // Redirect to login if no userId
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Fetch worker details and current status when component mounts
|
||||||
|
fetchWorkerDetails()
|
||||||
fetchCurrentStatus()
|
fetchCurrentStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -84,7 +99,13 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
|
|||||||
const response = await fetch('http://localhost:3000/api/clock', {
|
const response = await fetch('http://localhost:3000/api/clock', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await response.json()
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user