feat(考勤管理): 新增考勤记录查看、人员管理和报表生成功能

添加考勤记录查看页面,支持按日期筛选和展示员工考勤数据
实现人员管理组件,包含添加员工、搜索分页和删除功能
新增考勤报表生成组件,支持多员工筛选和导出CSV
This commit is contained in:
sudomarcma
2025-06-13 18:24:58 +08:00
parent c76fda9180
commit cac82a2c36
20 changed files with 1346 additions and 834 deletions
+1
View File
@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.env
.DS_Store
dist
dist-ssr
+303 -258
View File
@@ -2,269 +2,314 @@ import express from 'express'
import cors from 'cors'
import { Parser } from 'json2csv'
import { v4 as uuidv4 } from 'uuid'
import mysql from 'mysql2/promise'
import dotenv from 'dotenv'
const app = express()
const port = 3000
// Main function to start the server
async function startServer() {
dotenv.config()
app.use(cors())
app.use(express.json())
const app = express()
const port = 3000
// --- In-memory database for MVP ---
const users = [
{ id: 1, username: 'worker', password: 'password', role: 'worker', fullName: 'John Doe' },
{ id: 2, username: 'worker2', password: 'password', role: 'worker', fullName: 'Jane Smith' },
{ id: 3, username: 'manager', password: 'password', role: 'manager', fullName: 'Manager Bob' },
]
let qrCodes = [
{
id: 'FACTORY-MAIN-ENTRANCE',
name: 'Factory Main Entrance',
isActive: true,
createdAt: new Date().toISOString(),
},
{
id: 'WAREHOUSE-SECTION-A',
name: 'Warehouse Section A',
isActive: true,
createdAt: new Date().toISOString(),
},
{
id: 'ASSEMBLY-LINE-1',
name: 'Assembly Line 1',
isActive: false,
createdAt: new Date().toISOString(),
},
]
let clockEvents = [
// Sample data for testing reports
{
id: 1,
userId: 1,
eventType: 'clock_in',
timestamp: '2025-06-10T09:00:00.000Z',
qrCodeUsedId: 'FACTORY-MAIN-ENTRANCE',
qrCodeUsedName: 'Factory Main Entrance',
},
{
id: 2,
userId: 1,
eventType: 'clock_out',
timestamp: '2025-06-10T17:30:00.000Z',
qrCodeUsedId: 'FACTORY-MAIN-ENTRANCE',
qrCodeUsedName: 'Factory Main Entrance',
},
{
id: 3,
userId: 2,
eventType: 'clock_in',
timestamp: '2025-06-10T09:05:00.000Z',
qrCodeUsedId: 'WAREHOUSE-SECTION-A',
qrCodeUsedName: 'Warehouse Section A',
},
{
id: 4,
userId: 2,
eventType: 'clock_out',
timestamp: '2025-06-10T17:35:00.000Z',
qrCodeUsedId: 'WAREHOUSE-SECTION-A',
qrCodeUsedName: 'Warehouse Section A',
},
{
id: 5,
userId: 1,
eventType: 'clock_in',
timestamp: '2025-06-11T08:58:00.000Z',
qrCodeUsedId: 'FACTORY-MAIN-ENTRANCE',
qrCodeUsedName: 'Factory Main Entrance',
}, // Missing clock out
]
let eventId = clockEvents.length + 1
// --- Helper Functions ---
const calculateHours = (events) => {
const userHours = {}
const pairedEvents = {}
// Group events by user
events.forEach((event) => {
if (!pairedEvents[event.userId]) {
pairedEvents[event.userId] = []
}
pairedEvents[event.userId].push(event)
// --- Database Connection ---
const db = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT || 3306,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
for (const userId in pairedEvents) {
const userEvents = pairedEvents[userId].sort(
(a, b) => new Date(a.timestamp) - new Date(b.timestamp),
)
let totalHours = 0
let clockInTime = null
userEvents.forEach((event) => {
if (event.eventType === 'clock_in' && !clockInTime) {
clockInTime = new Date(event.timestamp)
} else if (event.eventType === 'clock_out' && clockInTime) {
const clockOutTime = new Date(event.timestamp)
const diffMs = clockOutTime - clockInTime
totalHours += diffMs / (1000 * 60 * 60)
clockInTime = null // Reset for next pair
}
})
const worker = users.find((u) => u.id === parseInt(userId))
userHours[userId] = {
userId,
fullName: worker ? worker.fullName : `User ${userId}`,
totalHours: parseFloat(totalHours.toFixed(2)),
hasIncomplete: clockInTime !== null, // Mark if there's a pending clock-in
}
try {
const connection = await db.getConnection()
console.log('Database connected successfully!')
connection.release()
} catch (error) {
console.error('!!! DATABASE CONNECTION FAILED !!!')
console.error('Error:', error.message)
process.exit(1)
}
return Object.values(userHours)
app.use(cors())
app.use(express.json())
// Helper functions can be placed here if any are needed in the future
// --- API Endpoints ---
// Auth Endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { username, password } = req.body
// In a real app, use a secure hashing library like bcrypt to compare passwords
const [rows] = await db.execute(
'SELECT id, role FROM workers WHERE username = ? AND password_hash = ?',
[username, password],
)
if (rows.length > 0) {
const user = rows[0]
res.json({ message: 'Login successful', role: user.role, userId: user.id })
} else {
res.status(401).json({ message: 'Invalid credentials' })
}
} catch (error) {
console.error('Login error:', error)
res.status(500).json({ message: 'Database error during login.' })
}
})
// Worker Clock In/Out Endpoint
app.post('/api/clock', async (req, res) => {
try {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
const [qrRows] = await db.execute('SELECT name, is_active FROM qr_codes WHERE id = ?', [
qrCodeValue,
])
if (qrRows.length === 0 || !qrRows[0].is_active) {
return res.status(400).json({ message: 'Invalid or inactive QR Code.' })
}
const [lastEventRows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId],
)
if (lastEventRows.length > 0 && lastEventRows[0].event_type === eventType) {
return res
.status(400)
.json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` })
}
const timestamp = new Date()
await db.execute(
'INSERT INTO clock_records (worker_id, event_type, timestamp, qr_code_id, latitude, longitude) VALUES (?, ?, ?, ?, ?, ?)',
[userId, eventType, timestamp, qrCodeValue, latitude, longitude],
)
res.status(201).json({ message: 'Clock event recorded successfully' })
} catch (error) {
console.error('Clock event error:', error)
res.status(500).json({ message: 'Database error during clock event.' })
}
})
// Worker Status Endpoint
app.get('/api/worker/status/:userId', async (req, res) => {
try {
const { userId } = req.params
const [rows] = await db.execute(
'SELECT event_type FROM clock_records WHERE worker_id = ? ORDER BY timestamp DESC LIMIT 1',
[userId],
)
if (rows.length > 0) {
res.json({ eventType: rows[0].event_type })
} else {
res.json({ eventType: 'clock_out' })
}
} catch (error) {
console.error('Worker status error:', error)
res.status(500).json({ message: 'Database error fetching status.' })
}
})
// Worker History Endpoint
app.get('/api/worker/clock-history/:userId', async (req, res) => {
try {
const { userId } = req.params
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`,
[userId],
)
res.json(rows)
} catch (error) {
console.error('Worker history error:', error)
res.status(500).json({ message: 'Database error fetching history.' })
}
})
// Manager: GET All Workers with Search and Pagination
app.get('/api/managers/workers', async (req, res) => {
try {
const { search = '', page = 1, limit = 20 } = req.query
const offset = (parseInt(page) - 1) * parseInt(limit)
const searchTerm = `%${search}%`
const [workers] = await db.execute(
`SELECT id, username, full_name, created_at FROM workers WHERE role = 'worker' AND (full_name LIKE ? OR username LIKE ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[searchTerm, searchTerm, parseInt(limit), offset],
)
const [[{ totalCount }]] = await db.execute(
`SELECT COUNT(*) as totalCount FROM workers WHERE role = 'worker' AND (full_name LIKE ? OR username LIKE ?)`,
[searchTerm, searchTerm],
)
res.json({ workers, totalCount })
} catch (error) {
console.error('Get workers error:', error)
res.status(500).json({ message: 'Database error fetching workers.' })
}
})
// Manager: POST (Add new) Worker
app.post('/api/managers/workers', async (req, res) => {
try {
const { username, password, fullName } = req.body
if (!username || !password || !fullName) {
return res.status(400).json({ message: 'Username, password, and full name are required.' })
}
const [result] = await db.execute(
"INSERT INTO workers (username, password_hash, full_name, role) VALUES (?, ?, ?, 'worker')",
[username, password, fullName],
)
res.status(201).json({ id: result.insertId, username, fullName })
} catch (error) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ message: 'Username already exists.' })
}
console.error('Add worker error:', error)
res.status(500).json({ message: 'Database error adding worker.' })
}
})
// Manager: DELETE Worker
app.delete('/api/managers/workers/:id', async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute("DELETE FROM workers WHERE id = ? AND role = 'worker'", [
id,
])
if (result.affectedRows === 0) {
return res.status(404).json({ message: 'Worker not found or user is not a worker.' })
}
res.status(204).send()
} catch (error) {
console.error('Delete worker error:', error)
res.status(500).json({ message: 'Database error deleting worker.' })
}
})
// Manager: GET Attendance Records
app.get('/api/managers/attendance-records', async (req, res) => {
try {
const { workerIds, startDate, endDate, format } = req.query
if (!workerIds) {
return res.status(400).json({ message: 'Worker IDs are required.' })
}
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})`
const params = [...idsArray]
if (startDate && endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999)
query += ' AND cr.timestamp BETWEEN ? AND ?'
params.push(startDate, endOfDay)
}
query += ' ORDER BY w.full_name, cr.timestamp DESC'
const [rows] = await db.execute(query, params)
if (format === 'csv') {
const json2csvParser = new Parser({
fields: ['full_name', 'event_type', 'timestamp', 'qrCodeUsedName'],
})
const csv = json2csvParser.parse(rows)
res.header('Content-Type', 'text/csv')
res.attachment(`attendance-report-${new Date().toISOString().split('T')[0]}.csv`)
return res.send(csv)
}
res.json(rows)
} catch (error) {
console.error('Attendance records error:', error)
res.status(500).json({ message: 'Database error fetching attendance records.' })
}
})
// Manager: GET QR Codes
app.get('/api/managers/qr-codes', async (req, res) => {
try {
const [rows] = await db.execute(
'SELECT id, name, is_active, created_at FROM qr_codes ORDER BY created_at DESC',
)
res.json(rows)
} catch (error) {
console.error('Get QR codes error:', error)
res.status(500).json({ message: 'Database error fetching QR codes.' })
}
})
// Manager: POST QR Code
app.post('/api/managers/qr-codes', async (req, res) => {
try {
const { name } = req.body
if (!name) return res.status(400).json({ message: 'QR Code name is required.' })
const newQrCode = { id: uuidv4(), name, isActive: true }
await db.execute('INSERT INTO qr_codes (id, name, is_active) VALUES (?, ?, ?)', [
newQrCode.id,
newQrCode.name,
newQrCode.isActive,
])
res
.status(201)
.json({ id: newQrCode.id, name: newQrCode.name, is_active: newQrCode.isActive })
} catch (error) {
console.error('Add QR code error:', error)
res.status(500).json({ message: 'Database error adding QR code.' })
}
})
// Manager: PUT QR Code
app.put('/api/managers/qr-codes/:id', async (req, res) => {
try {
const { id } = req.params
const { isActive } = req.body
if (typeof isActive !== 'boolean')
return res.status(400).json({ message: 'isActive must be a boolean.' })
const [result] = await db.execute('UPDATE qr_codes SET is_active = ? WHERE id = ?', [
isActive,
id,
])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.json({ id, isActive })
} catch (error) {
console.error('Update QR code error:', error)
res.status(500).json({ message: 'Database error updating QR code.' })
}
})
// Manager: DELETE QR Code
app.delete('/api/managers/qr-codes/:id', async (req, res) => {
try {
const { id } = req.params
const [result] = await db.execute('DELETE FROM qr_codes WHERE id = ?', [id])
if (result.affectedRows === 0) return res.status(404).json({ message: 'QR Code not found.' })
res.status(204).send()
} catch (error) {
console.error('Delete QR code error:', error)
res.status(500).json({ message: 'Database error deleting QR code.' })
}
})
// Manager: GET single worker's details
app.get('/api/managers/worker/: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(rows[0])
} else {
res.status(404).json({ message: 'Worker not found.' })
}
} catch (error) {
console.error('Get single worker error:', error)
res.status(500).json({ message: 'Database error fetching worker details.' })
}
})
// --- Server Start ---
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`)
})
}
// --- API Endpoints ---
// Auth Endpoint
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body
const user = users.find((u) => u.username === username && u.password === password)
if (user) {
res.json({ message: 'Login successful', role: user.role, userId: user.id })
} else {
res.status(401).json({ message: 'Invalid credentials' })
}
})
// Worker Clock In/Out Endpoint
app.post('/api/clock', (req, res) => {
const { userId, eventType, qrCodeValue, latitude, longitude } = req.body
const validQrCode = qrCodes.find((qr) => qr.id === qrCodeValue && qr.isActive)
if (!validQrCode) return res.status(400).json({ message: 'Invalid or inactive QR Code.' })
const lastEvent = clockEvents.filter((e) => e.userId === userId).pop()
if (lastEvent && lastEvent.eventType === eventType)
return res
.status(400)
.json({ message: `You are already clocked ${eventType === 'clock_in' ? 'in' : 'out'}.` })
const newEvent = {
id: eventId++,
userId,
eventType,
timestamp: new Date().toISOString(),
qrCodeUsedId: qrCodeValue,
qrCodeUsedName: validQrCode.name, // Add human-readable name
latitude,
longitude,
}
clockEvents.push(newEvent)
res.status(201).json(newEvent)
})
// Worker Status Endpoint
app.get('/api/worker/status/:userId', (req, res) => {
const userId = parseInt(req.params.userId, 10)
const lastEvent = clockEvents.filter((event) => event.userId === userId).pop()
if (lastEvent) {
res.json(lastEvent)
} else {
res.json({ eventType: 'clock_out' })
}
})
// Worker History Endpoint
app.get('/api/worker/clock-history/:userId', (req, res) => {
const userId = parseInt(req.params.userId, 10)
const userEvents = clockEvents.filter((event) => event.userId === userId)
res.json(userEvents)
})
// --- Manager Endpoints ---
// Reporting Endpoint
app.get('/api/managers/hours-report', (req, res) => {
const { startDate, endDate, format } = req.query
let filteredEvents = clockEvents
if (startDate && endDate) {
const endOfDay = new Date(endDate)
endOfDay.setHours(23, 59, 59, 999) // Include the whole end day
filteredEvents = clockEvents.filter((event) => {
const eventDate = new Date(event.timestamp)
return eventDate >= new Date(startDate) && eventDate <= endOfDay
})
}
const reportData = calculateHours(filteredEvents)
if (format === 'csv') {
const json2csvParser = new Parser({
fields: ['userId', 'fullName', 'totalHours', 'hasIncomplete'],
})
const csv = json2csvParser.parse(reportData)
res.header('Content-Type', 'text/csv')
res.attachment(`hours-report-${new Date().toISOString().split('T')[0]}.csv`)
return res.send(csv)
}
res.json(reportData)
})
// GET QR Codes Endpoint
app.get('/api/managers/qr-codes', (req, res) => {
res.json(qrCodes.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)))
})
// POST (Add new) QR Code Endpoint
app.post('/api/managers/qr-codes', (req, res) => {
const { name } = req.body
if (!name) {
return res.status(400).json({ message: 'QR Code name is required.' })
}
const newQrCode = {
id: uuidv4(),
name,
isActive: true,
createdAt: new Date().toISOString(),
}
qrCodes.push(newQrCode)
res.status(201).json(newQrCode)
})
// PUT (Update) QR Code Endpoint
app.put('/api/managers/qr-codes/:id', (req, res) => {
const { id } = req.params
const { isActive } = req.body
const qrCodeIndex = qrCodes.findIndex((qr) => qr.id === id)
if (qrCodeIndex === -1) {
return res.status(404).json({ message: 'QR Code not found.' })
}
if (typeof isActive !== 'boolean') {
return res.status(400).json({ message: 'isActive must be a boolean.' })
}
qrCodes[qrCodeIndex].isActive = isActive
res.json(qrCodes[qrCodeIndex])
})
// DELETE QR Code Endpoint
app.delete('/api/managers/qr-codes/:id', (req, res) => {
const { id } = req.params
const initialLength = qrCodes.length
qrCodes = qrCodes.filter((qr) => qr.id !== id)
if (qrCodes.length === initialLength) {
return res.status(404).json({ message: 'QR Code not found.' })
}
res.status(204).send()
})
// --- Server Start ---
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`)
})
startServer()
+123
View File
@@ -10,9 +10,11 @@
"dependencies": {
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"html5-qrcode": "^2.3.8",
"json2csv": "^6.0.0-alpha.2",
"mysql2": "^3.14.1",
"qrcode": "^1.5.4",
"uuid": "^11.1.0",
"vue": "^3.5.13",
@@ -1934,6 +1936,15 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2363,6 +2374,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2378,6 +2398,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3034,6 +3066,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -3384,6 +3425,12 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-stream": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
@@ -3613,6 +3660,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3623,6 +3676,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
"integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
@@ -3719,6 +3787,47 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.1.tgz",
"integrity": "sha512-7ytuPQJjQB8TNAYX/H2yhL+iQOnIBjAMam361R7UAL0lOVXWjtdrmoL9HYKqKoLp/8UUTRcvo1QPvK9KL7wA8w==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
"seq-queue": "^0.0.5",
"sqlstring": "^2.3.2"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
"integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
"license": "MIT",
"dependencies": {
"lru-cache": "^7.14.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/named-placeholders/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
"integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -4373,6 +4482,11 @@
"node": ">= 18"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
@@ -4542,6 +4656,15 @@
"node": ">=0.10.0"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+2
View File
@@ -13,9 +13,11 @@
"dependencies": {
"body-parser": "^2.2.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"html5-qrcode": "^2.3.8",
"json2csv": "^6.0.0-alpha.2",
"mysql2": "^3.14.1",
"qrcode": "^1.5.4",
"uuid": "^11.1.0",
"vue": "^3.5.13",
+34 -6
View File
@@ -2,9 +2,12 @@
<div class="app-container">
<header class="app-header">
<h1>Clock-In/Out System</h1>
<button @click="toggleTheme" class="theme-toggle" title="Toggle Theme">
{{ isDarkMode ? '☀️' : '🌙' }}
</button>
<div class="header-actions">
<button v-if="isLoggedIn" @click="logout" class="button-secondary">Logout</button>
<button @click="toggleTheme" class="theme-toggle" title="Toggle Theme">
{{ isDarkMode ? '☀️' : '🌙' }}
</button>
</div>
</header>
<main>
<RouterView />
@@ -13,10 +16,30 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
const isDarkMode = ref(false)
const router = useRouter()
const route = useRoute()
// --- ALIGNMENT CHANGE ---
const isLoggedIn = ref(!!sessionStorage.getItem('userId'))
// Watch for route changes to update login status
watch(
() => route.path,
() => {
isLoggedIn.value = !!sessionStorage.getItem('userId')
},
)
const logout = () => {
sessionStorage.removeItem('userId')
sessionStorage.removeItem('userRole')
isLoggedIn.value = false
router.push('/')
}
const toggleTheme = () => {
isDarkMode.value = !isDarkMode.value
@@ -40,6 +63,12 @@ onMounted(() => {
</script>
<style scoped>
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
/* Other styles remain the same */
.app-header {
display: flex;
justify-content: space-between;
@@ -68,7 +97,6 @@ onMounted(() => {
main {
padding: 1rem;
}
@media (min-width: 768px) {
main {
padding: 2rem;
+1
View File
@@ -17,6 +17,7 @@
--c-primary-text: #ffffff;
--c-success: #31a24c;
--c-danger: #e41e3f;
--c-secondary: #05cece;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+327
View File
@@ -0,0 +1,327 @@
<template>
<div class="attendance-reporting-layout">
<!-- Left Panel: Worker Selection -->
<div class="selection-panel">
<section class="card">
<h3 class="panel-header">1. Select Workers</h3>
<div class="search-box">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name..."
class="form-input"
@keydown.enter.prevent="handleSearch"
@keydown.down.prevent="navigateResults(1)"
@keydown.esc.prevent="clearSearch"
/>
<div v-if="searchResults.length > 0" class="search-results-list">
<ul>
<li
v-for="(worker, index) in searchResults"
:key="worker.id"
:class="{ highlighted: index === highlightedIndex }"
@click="selectWorker(worker)"
@mouseenter="highlightedIndex = index"
>
{{ worker.full_name }}
</li>
</ul>
</div>
</div>
<div class="selected-workers-list">
<h4>Selected for Report ({{ selectedWorkers.length }})</h4>
<ul v-if="selectedWorkers.length > 0">
<li v-for="worker in selectedWorkers" :key="worker.id">
<span>{{ worker.full_name }}</span>
<button @click="removeWorker(worker.id)" class="remove-btn">×</button>
</li>
</ul>
<p v-else class="empty-state">No workers selected.</p>
</div>
</section>
</div>
<!-- Right Panel: Filters & Results -->
<div class="results-panel">
<section class="card">
<h3 class="panel-header">2. Set Filters & Generate</h3>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
<input type="date" id="start-date" v-model="filters.startDate" class="form-input" />
</div>
<div class="form-group">
<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>
<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-for="(group, workerName) in groupedReportData"
:key="workerName"
class="worker-group"
>
<h5 class="worker-group-header">{{ workerName }}</h5>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location</th>
</tr>
</thead>
<tbody>
<tr v-for="record in group" :key="record.id">
<td>
<span class="event-type" :class="record.event_type">{{
record.event_type.replace('_', ' ')
}}</span>
</td>
<td>{{ new Date(record.timestamp).toLocaleString() }}</td>
<td>{{ record.qrCodeUsedName }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<p v-else class="empty-state">Generate a report to see results.</p>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
// --- STATE ---
const searchQuery = ref('')
const searchResults = ref([])
const highlightedIndex = ref(-1)
const selectedWorkers = ref([])
const filters = ref({ startDate: '', endDate: '' })
const loadingReport = ref(false)
const reportData = ref([])
// --- COMPUTED ---
const canGenerate = computed(
() => selectedWorkers.value.length > 0 && filters.value.startDate && filters.value.endDate,
)
const groupedReportData = computed(() => {
return reportData.value.reduce((groups, record) => {
const key = record.full_name
if (!groups[key]) {
groups[key] = []
}
groups[key].push(record)
return groups
}, {})
})
// --- METHODS ---
const handleSearch = () => {
if (highlightedIndex.value >= 0 && searchResults.value[highlightedIndex.value]) {
selectWorker(searchResults.value[highlightedIndex.value])
} else {
fetchWorkers()
}
}
const fetchWorkers = async () => {
try {
const res = await fetch(
`http://localhost:3000/api/managers/workers?search=${searchQuery.value}&limit=10`,
)
if (res.ok) {
const data = await res.json()
searchResults.value = data.workers
highlightedIndex.value = data.workers.length > 0 ? 0 : -1
}
} catch (err) {
console.error('Failed to search workers', err)
}
}
const clearSearch = () => {
searchQuery.value = ''
searchResults.value = []
highlightedIndex.value = -1
}
const navigateResults = (direction) => {
if (searchResults.value.length === 0) return
const newIndex = highlightedIndex.value + direction
if (newIndex >= 0 && newIndex < searchResults.value.length) {
highlightedIndex.value = newIndex
}
}
const selectWorker = (worker) => {
if (!selectedWorkers.value.some((w) => w.id === worker.id)) {
selectedWorkers.value.push(worker)
}
clearSearch()
}
const removeWorker = (workerId) => {
selectedWorkers.value = selectedWorkers.value.filter((w) => w.id !== workerId)
}
const generateReport = async () => {
if (!canGenerate.value) return
loadingReport.value = true
reportData.value = []
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()
}
} catch (err) {
console.error('Failed to generate report', err)
} 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')
}
onMounted(() => {
const today = new Date()
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]
})
</script>
<style scoped>
.attendance-reporting-layout {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
align-items: flex-start;
}
.panel-header {
margin-top: 0;
}
.search-box {
position: relative;
}
.search-results-list {
position: absolute;
width: 100%;
background-color: var(--c-bg-secondary);
border: 1px solid var(--c-border);
border-radius: var(--radius);
margin-top: 0.5rem;
z-index: 10;
max-height: 200px;
overflow-y: auto;
}
.search-results-list ul {
list-style: none;
margin: 0;
padding: 0.5rem;
}
.search-results-list li {
padding: 0.75rem 1rem;
cursor: pointer;
border-radius: 6px;
}
.search-results-list li.highlighted {
background-color: var(--c-primary);
color: var(--c-primary-text);
}
.selected-workers-list {
margin-top: 1.5rem;
}
.selected-workers-list ul {
list-style: none;
padding: 0;
margin: 0;
max-height: 300px;
overflow-y: auto;
}
.selected-workers-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-radius: 6px;
}
.selected-workers-list li:nth-child(odd) {
background-color: var(--c-bg-primary);
}
.remove-btn {
background: none;
border: none;
color: var(--c-danger);
font-size: 1.5rem;
cursor: pointer;
}
.empty-state {
color: var(--c-text-secondary);
text-align: center;
padding: 1rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.action-buttons {
margin-left: auto;
display: flex;
gap: 1rem;
}
.results-display h4 {
border-bottom: 1px solid var(--c-border);
padding-bottom: 0.75rem;
}
.worker-group {
margin-top: 2rem;
}
.worker-group-header {
padding: 0.5rem 1rem;
background-color: var(--c-bg-primary);
border-radius: 6px;
}
.event-type {
padding: 4px 8px;
border-radius: 6px;
color: var(--c-primary-text);
font-size: 0.85rem;
text-transform: capitalize;
}
.event-type.clock_in {
background-color: var(--c-success);
}
.event-type.clock_out {
background-color: var(--c-danger);
}
</style>
-44
View File
@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
-169
View File
@@ -1,169 +0,0 @@
<template>
<div class="report-container">
<section class="card">
<h2 class="card-header">Hours Report</h2>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
<input type="date" id="start-date" v-model="reportFilters.startDate" class="form-input" />
</div>
<div class="form-group">
<label for="end-date">End Date</label>
<input type="date" id="end-date" v-model="reportFilters.endDate" class="form-input" />
</div>
<div class="filter-actions">
<button @click="fetchHoursReport" :disabled="loadingReport" class="button-primary">
{{ loadingReport ? 'Loading...' : 'Generate Report' }}
</button>
<button
@click="exportReportAsCsv"
:disabled="!reportData.length"
class="button-secondary"
>
Export as CSV
</button>
</div>
</div>
<div v-if="loadingReport" class="loading-placeholder">Loading report data...</div>
<div v-if="!loadingReport && reportData.length > 0">
<table>
<thead>
<tr>
<th>Worker</th>
<th>Total Hours</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData" :key="item.userId">
<td>{{ item.fullName }}</td>
<td>{{ item.totalHours }}</td>
<td>
<span v-if="item.hasIncomplete" class="status-badge incomplete"> Incomplete </span>
<span v-else class="status-badge complete"> Complete </span>
</td>
</tr>
</tbody>
</table>
<p class="summary-total">
<strong>Total Collective Hours: {{ collectiveHours.toFixed(2) }}</strong>
</p>
</div>
<p v-if="!loadingReport && !reportData.length && hasGeneratedReport" class="no-data-message">
No data found for the selected period.
</p>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
const reportFilters = ref({ startDate: '', endDate: '' })
const reportData = ref([])
const loadingReport = ref(false)
const hasGeneratedReport = ref(false)
const collectiveHours = computed(() => {
return reportData.value.reduce((total, item) => total + item.totalHours, 0)
})
onMounted(() => {
const today = new Date()
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(today.getDate() - 7)
reportFilters.value.endDate = today.toISOString().split('T')[0]
reportFilters.value.startDate = sevenDaysAgo.toISOString().split('T')[0]
})
const fetchHoursReport = async () => {
if (!reportFilters.value.startDate || !reportFilters.value.endDate) {
alert('Please select both a start and end date.')
return
}
loadingReport.value = true
hasGeneratedReport.value = true
reportData.value = []
const url = `http://localhost:3000/api/managers/hours-report?startDate=${reportFilters.value.startDate}&endDate=${reportFilters.value.endDate}`
try {
const res = await fetch(url)
if (res.ok) {
reportData.value = await res.json()
}
} catch (err) {
console.error('Failed to fetch report:', err)
} finally {
loadingReport.value = false
}
}
const exportReportAsCsv = () => {
const url = `http://localhost:3000/api/managers/hours-report?startDate=${reportFilters.value.startDate}&endDate=${reportFilters.value.endDate}&format=csv`
window.open(url, '_blank')
}
</script>
<style scoped>
.card-header {
margin-top: 0;
margin-bottom: 1.5rem;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: flex-end;
margin-bottom: 2rem;
padding: 1.5rem;
background-color: var(--c-bg-primary);
border-radius: var(--radius);
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 500;
color: var(--c-text-secondary);
}
.filter-actions {
display: flex;
gap: 1rem;
margin-left: auto;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 600;
}
.status-badge.incomplete {
background-color: #f0a10033;
color: #d18c00;
}
.dark .status-badge.incomplete {
color: #f0a100;
}
.status-badge.complete {
background-color: #45bd6233;
color: var(--c-success);
}
.summary-total {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--c-border);
font-size: 1.2rem;
text-align: right;
}
.loading-placeholder,
.no-data-message {
text-align: center;
padding: 3rem;
color: var(--c-text-secondary);
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="personnel-container">
<!-- Add Worker Section -->
<section class="card">
<h2 class="card-header">Add New Worker</h2>
<div class="add-worker-form">
<div class="form-group">
<label for="fullName">Full Name</label>
<input
type="text"
id="fullName"
v-model="newWorker.fullName"
class="form-input"
placeholder="e.g., John Smith"
/>
</div>
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
v-model="newWorker.username"
class="form-input"
placeholder="e.g., jsmith"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
v-model="newWorker.password"
class="form-input"
placeholder="Create a temporary password"
/>
</div>
<button @click="addWorker" :disabled="!isFormValid || loading" class="button-primary">
{{ loading ? 'Adding...' : 'Add Worker' }}
</button>
</div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</section>
<!-- Roster Section -->
<section class="card">
<h2 class="card-header">Worker Roster</h2>
<div class="roster-controls">
<input
type="text"
v-model="searchQuery"
placeholder="Search by name or username..."
class="form-input search-input"
@keyup.enter="fetchWorkers(1)"
/>
</div>
<table>
<thead>
<tr>
<th>Full Name</th>
<th>Username</th>
<th>Date Joined</th>
<th class="actions-header">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && workers.length === 0">
<td colspan="4" class="loading-state">Loading workers...</td>
</tr>
<tr v-if="!loading && workers.length === 0">
<td colspan="4" class="empty-state">No workers found.</td>
</tr>
<tr v-for="worker in workers" :key="worker.id">
<td>{{ worker.full_name }}</td>
<td>{{ worker.username }}</td>
<td>{{ new Date(worker.created_at).toLocaleDateString() }}</td>
<td class="actions-cell">
<button @click="viewRecords(worker.id)" class="button-secondary">View Records</button>
<button @click="deleteWorker(worker.id)" class="button-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
<div class="pagination-controls" v-if="totalPages > 1">
<button @click="changePage(currentPage - 1)" :disabled="currentPage <= 1">Previous</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button @click="changePage(currentPage + 1)" :disabled="currentPage >= totalPages">
Next
</button>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const workers = ref([])
const loading = ref(false)
const errorMessage = ref('')
// Form State
const newWorker = ref({ fullName: '', username: '', password: '' })
// Search & Pagination State
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(20) // Or whatever default you prefer
const totalWorkers = ref(0)
// --- COMPUTED ---
const isFormValid = computed(
() => newWorker.value.fullName && newWorker.value.username && newWorker.value.password,
)
const totalPages = computed(() => Math.ceil(totalWorkers.value / pageSize.value))
// --- METHODS ---
let searchDebounce = null
watch(searchQuery, () => {
clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
fetchWorkers(1) // Reset to page 1 on new search
}, 500) // Debounce search for 500ms
})
const fetchWorkers = async (page = currentPage.value) => {
loading.value = true
try {
const res = await fetch(
`http://localhost:3000/api/managers/workers?search=${searchQuery.value}&page=${page}&limit=${pageSize.value}`,
)
if (res.ok) {
const data = await res.json()
workers.value = data.workers
totalWorkers.value = data.totalCount
currentPage.value = page
}
} catch (err) {
errorMessage.value = 'Failed to fetch workers.'
console.error(err)
} finally {
loading.value = false
}
}
const changePage = (page) => {
if (page > 0 && page <= totalPages.value) {
fetchWorkers(page)
}
}
const addWorker = async () => {
if (!isFormValid.value) return
loading.value = true
errorMessage.value = ''
try {
const res = await fetch('http://localhost:3000/api/managers/workers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newWorker.value),
})
const data = await res.json()
if (res.ok) {
await fetchWorkers(1) // Refresh list to the first page
newWorker.value = { fullName: '', username: '', password: '' } // Clear form
} else {
errorMessage.value = data.message
}
} catch (err) {
errorMessage.value = 'An error occurred while adding the worker.'
console.error(err)
} finally {
loading.value = false
}
}
const deleteWorker = async (id) => {
if (!confirm('Are you sure you want to delete this worker account?')) return
try {
const res = await fetch(`http://localhost:3000/api/managers/workers/${id}`, {
method: 'DELETE',
})
if (res.ok) {
// If the deleted worker was the last on the page, go to the previous page
if (workers.value.length === 1 && currentPage.value > 1) {
await fetchWorkers(currentPage.value - 1)
} else {
await fetchWorkers(currentPage.value)
}
}
} catch (err) {
errorMessage.value = 'Failed to delete worker.'
console.error(err)
}
}
const viewRecords = (workerIds) => {
console.log(
`[DEBUG] 1. fetchWorkerDetails called with ID: '${workerIds}' (Type: ${typeof workerIds})`,
)
router.push(`/manager/attendance/${workerIds}`)
}
onMounted(() => {
const userRole = sessionStorage.getItem('userRole')
if (userRole !== 'manager') {
router.push('/')
return
}
fetchWorkers()
})
</script>
<style scoped>
.personnel-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.card-header {
margin-top: 0;
margin-bottom: 1.5rem;
}
.add-worker-form {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 1rem;
align-items: flex-end;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 500;
}
.error-message {
color: var(--c-danger);
margin-top: 1rem;
}
.roster-controls {
margin-bottom: 1.5rem;
}
.search-input {
width: 100%;
max-width: 400px;
}
.actions-header {
text-align: right;
}
.actions-cell {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.empty-state,
.loading-state {
text-align: center;
padding: 2rem;
color: var(--c-text-secondary);
}
.pagination-controls {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--c-border);
}
</style>
+24 -7
View File
@@ -43,16 +43,18 @@
<tr v-for="qr in qrCodes" :key="qr.id">
<td>{{ qr.name }}</td>
<td>
<span class="status-badge" :class="qr.isActive ? 'active' : 'inactive'">
{{ qr.isActive ? 'Active' : 'Inactive' }}
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
<span class="status-badge" :class="qr.is_active ? 'active' : 'inactive'">
{{ qr.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions-cell">
<button @click="downloadQrCode(qr)" class="button-secondary" title="Download QR Code">
<span></span> Download
</button>
<!-- FIX #1: Use qr.is_active instead of qr.isActive -->
<button @click="toggleQrStatus(qr)" class="button-secondary">
{{ qr.isActive ? 'Deactivate' : 'Activate' }}
{{ qr.is_active ? 'Deactivate' : 'Activate' }}
</button>
<button @click="deleteQrCode(qr.id)" class="button-danger">Delete</button>
</td>
@@ -65,14 +67,21 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
const router = useRouter()
const qrCodes = ref([])
const newQrName = ref('')
const newlyGeneratedQr = ref(null)
const newQrCanvas = ref(null)
onMounted(() => {
const userRole = sessionStorage.getItem('userRole')
if (userRole !== 'manager') {
router.push('/')
return
}
fetchQrCodes()
})
@@ -118,12 +127,16 @@ const toggleQrStatus = async (qr) => {
const res = await fetch(`http://localhost:3000/api/managers/qr-codes/${qr.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !qr.isActive }),
// Send the opposite of the current status
body: JSON.stringify({ isActive: !qr.is_active }),
})
if (res.ok) {
const updatedQr = await res.json()
const index = qrCodes.value.findIndex((q) => q.id === updatedQr.id)
if (index !== -1) qrCodes.value[index] = updatedQr
// FIX #2: Instead of replacing the object, just update the property.
// This preserves the 'name' and other properties of the object.
const index = qrCodes.value.findIndex((q) => q.id === qr.id)
if (index !== -1) {
qrCodes.value[index].is_active = !qrCodes.value[index].is_active
}
}
} catch (err) {
console.error('Failed to update QR status:', err)
@@ -168,6 +181,7 @@ const downloadQrCode = async (qr) => {
</script>
<style scoped>
/* Styles remain the same */
.qr-management-container {
display: flex;
flex-direction: column;
@@ -244,3 +258,6 @@ const downloadQrCode = async (qr) => {
gap: 0.5rem;
}
</style>
``` These targeted fixes should resolve the issues completely. The component will now correctly
display the status from the database on initial load and will properly update the state when you
activate or deactivate a QR co
-94
View File
@@ -1,94 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
-115
View File
@@ -1,115 +0,0 @@
<template>
<div class="worker-management-container">
<section class="card worker-list-card">
<h2 class="card-header">All Workers</h2>
<ul class="worker-list">
<li
v-for="worker in workers"
:key="worker.id"
@click="selectWorker(worker)"
:class="{ active: selectedWorker?.id === worker.id }"
class="worker-list-item"
>
{{ worker.fullName }}
</li>
</ul>
</section>
<section v-if="selectedWorker" class="card worker-details-card">
<h2 class="card-header">Details for {{ selectedWorker.fullName }}</h2>
<div v-if="loadingDetails">Loading details...</div>
<div v-else>
<h3>Total Hours</h3>
<p>{{ totalHours.toFixed(2) }} hours worked (all time)</p>
<h3>Clock History (Latest First)</h3>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>QR Code Name</th>
</tr>
</thead>
<tbody>
<tr v-for="event in clockHistory" :key="event.id">
<td>{{ event.eventType }}</td>
<td>{{ new Date(event.timestamp).toLocaleString() }}</td>
<td>{{ event.qrCodeUsedName }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<p v-else class="prompt-text">Select a worker from the list to view their details.</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const workers = ref([])
const selectedWorker = ref(null)
const clockHistory = ref([])
const totalHours = ref(0)
const loadingDetails = ref(false)
onMounted(async () => {
const res = await fetch('http://localhost:3000/api/managers/users')
workers.value = await res.json()
})
const selectWorker = async (worker) => {
selectedWorker.value = worker
loadingDetails.value = true
// Fetch both history and total hours in parallel
const [historyRes, hoursRes] = await Promise.all([
fetch(`http://localhost:3000/api/users/${worker.id}/clock-history`),
fetch(`http://localhost:3000/api/managers/hours-report?userId=${worker.id}`),
])
if (historyRes.ok) clockHistory.value = await historyRes.json()
if (hoursRes.ok) {
const hoursData = await hoursRes.json()
totalHours.value = hoursData[0]?.totalHours || 0
}
loadingDetails.value = false
}
</script>
<style scoped>
.worker-management-container {
display: grid;
grid-template-columns: 300px 1fr;
gap: 2rem;
align-items: flex-start;
}
.worker-list {
list-style: none;
padding: 0;
margin: 0;
}
.worker-list-item {
padding: 1rem;
border-bottom: 1px solid var(--c-border);
cursor: pointer;
transition: background-color 0.2s;
}
.worker-list-item:hover {
background-color: var(--c-bg-primary);
}
.worker-list-item.active {
background-color: var(--c-primary);
color: var(--c-primary-text);
font-weight: 600;
}
.card-header {
margin-top: 0;
}
.prompt-text {
padding: 2rem;
text-align: center;
color: var(--c-text-secondary);
}
</style>
+37 -5
View File
@@ -3,31 +3,63 @@ import LoginView from '../views/LoginView.vue'
import WorkerDashboardView from '../views/WorkerDashboardView.vue'
import ManagerDashboardView from '../views/ManagerDashboardView.vue'
import WorkerHistoryView from '../views/WorkerHistoryView.vue'
import AttendanceRecordView from '../views/AttendanceRecordView.vue' // <-- Import new view
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'login',
component: LoginView,
},
{ path: '/', name: 'login', component: LoginView },
{
path: '/worker/dashboard',
name: 'worker-dashboard',
component: WorkerDashboardView,
meta: { requiresAuth: true, role: 'worker' },
},
{
path: '/worker/history',
name: 'worker-history',
component: WorkerHistoryView,
meta: { requiresAuth: true, role: 'worker' },
},
{
path: '/manager/dashboard',
name: 'manager-dashboard',
component: ManagerDashboardView,
meta: { requiresAuth: true, role: 'manager' },
},
// --- ADD THIS NEW ROUTE ---
{
path: '/manager/attendance/:workerId',
name: 'manager-attendance-record',
component: AttendanceRecordView,
meta: { requiresAuth: true, role: 'manager' },
},
],
})
// --- ALIGNMENT CHANGE: Navigation Guard ---
router.beforeEach((to, from, next) => {
const isLoggedIn = !!sessionStorage.getItem('userId')
const userRole = sessionStorage.getItem('userRole')
if (to.meta.requiresAuth) {
if (isLoggedIn) {
// Check if user has the required role
if (to.meta.role && to.meta.role === userRole) {
next() // User is logged in and has the correct role
} else {
// User is logged in but trying to access a page for another role
// Redirect them to their own dashboard
next(userRole === 'worker' ? '/worker/dashboard' : '/manager/dashboard')
}
} else {
// User is not logged in, redirect to login page
next('/')
}
} else {
// For public routes like the login page
next()
}
})
export default router
+139
View File
@@ -0,0 +1,139 @@
<template>
<div class="attendance-container">
<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>
</div>
<div class="filters">
<div class="form-group">
<label for="start-date">Start Date</label>
<input type="date" id="start-date" v-model="filters.startDate" class="form-input" />
</div>
<div class="form-group">
<label for="end-date">End Date</label>
<input type="date" id="end-date" v-model="filters.endDate" class="form-input" />
</div>
<button @click="fetchRecords" class="button-primary">Filter Records</button>
</div>
<table>
<thead>
<tr>
<th>Event</th>
<th>Timestamp</th>
<th>Location Name</th>
<th>Coordinates</th>
</tr>
</thead>
<tbody>
<tr v-if="!records.length">
<td colspan="4" style="text-align: center; padding: 2rem">
No records found for this period.
</td>
</tr>
<tr v-for="record in records" :key="record.id">
<td>
<span class="event-type" :class="record.event_type">{{
record.event_type.replace('_', ' ')
}}</span>
</td>
<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'
}}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const records = ref([])
const workerName = ref('')
const workerId = route.params.workerId
// 设置默认日期范围为过去7天
const today = new Date()
const sevenDaysAgo = new Date(today)
sevenDaysAgo.setDate(today.getDate() - 7)
const filters = ref({
startDate: sevenDaysAgo.toISOString().split('T')[0],
endDate: today.toISOString().split('T')[0],
})
const fetchRecords = async () => {
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) {
workerName.value = records.value[0].full_name
}
}
} catch (err) {
console.error('Failed to fetch attendance records:', err)
}
}
onMounted(() => {
fetchRecords()
})
</script>
<style scoped>
.attendance-container {
max-width: 1000px;
margin: auto;
}
.header {
margin-bottom: 1.5rem;
}
.back-link {
color: var(--c-primary);
text-decoration: none;
font-weight: 500;
}
.card-header {
margin-top: 0.5rem;
}
.filters {
display: flex;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.event-type {
padding: 4px 8px;
border-radius: 6px;
color: var(--c-primary-text);
font-size: 0.85rem;
text-transform: capitalize;
}
.event-type.clock_in {
background-color: var(--c-success);
}
.event-type.clock_out {
background-color: var(--c-danger);
}
</style>
+14 -2
View File
@@ -12,7 +12,9 @@
<input type="password" id="password" v-model="password" class="form-input" required />
</div>
<p class="info">Hint: worker/password or manager/password</p>
<button type="submit" class="button-primary login-button">Login</button>
<button type="submit" class="button-primary login-button" :disabled="loading">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
</div>
@@ -27,8 +29,11 @@ const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const handleLogin = async () => {
loading.value = true
error.value = ''
try {
const response = await fetch('http://localhost:3000/api/auth/login', {
method: 'POST',
@@ -39,7 +44,11 @@ const handleLogin = async () => {
const data = await response.json()
if (response.ok) {
// In a real app, you would save a token (e.g., JWT) here
// --- ALIGNMENT CHANGE ---
// Store user info in session storage
sessionStorage.setItem('userId', data.userId)
sessionStorage.setItem('userRole', data.role)
if (data.role === 'worker') {
router.push('/worker/dashboard')
} else if (data.role === 'manager') {
@@ -50,11 +59,14 @@ const handleLogin = async () => {
}
} catch {
error.value = 'Failed to connect to the server.'
} finally {
loading.value = false
}
}
</script>
<style scoped>
/* Styles remain the same */
.login-wrapper {
display: flex;
justify-content: center;
+12 -6
View File
@@ -1,31 +1,36 @@
<template>
<div class="manager-dashboard">
<div class="tabs">
<button @click="activeTab = 'reports'" :class="{ active: activeTab === 'reports' }">
Hours Report
<button @click="activeTab = 'personnel'" :class="{ active: activeTab === 'personnel' }">
Personnel Management
</button>
<button @click="activeTab = 'attendance'" :class="{ active: activeTab === 'attendance' }">
Attendance & Reporting
</button>
<button @click="activeTab = 'qr'" :class="{ active: activeTab === 'qr' }">
QR Code Management
</button>
</div>
<div class="tab-content">
<HoursReport v-if="activeTab === 'reports'" />
<AttendanceReporting v-if="activeTab === 'attendance'" />
<QrCodeManagement v-if="activeTab === 'qr'" />
<PersonnelManagement v-if="activeTab === 'personnel'" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import HoursReport from '@/components/HoursReport.vue'
import AttendanceReporting from '@/components/AttendanceReporting.vue'
import QrCodeManagement from '@/components/QrCodeManagement.vue'
import PersonnelManagement from '@/components/PersonnelManagement.vue'
const activeTab = ref('reports')
const activeTab = ref('personnel')
</script>
<style scoped>
.manager-dashboard {
max-width: 1200px;
max-width: 100%;
margin: auto;
}
.tabs {
@@ -43,6 +48,7 @@ const activeTab = ref('reports')
color: var(--c-text-secondary);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.tabs button.active {
color: var(--c-primary);
+36 -36
View File
@@ -3,18 +3,16 @@
<div class="status-card" :class="isClockedIn ? 'clocked-in' : 'clocked-out'">
<div class="status-icon">
<span v-if="isClockedIn"></span>
<span v-else></span>
<span v-else>🙏</span>
</div>
<div class="status-text">
<p>Your Status</p>
<h2>{{ clockStatus }}</h2>
</div>
</div>
<div class="action-panel card">
<p v-if="successMessage" class="message success">{{ successMessage }}</p>
<p v-if="errorMessage" class="message error">{{ errorMessage }}</p>
<div v-if="!isScannerActive" class="action-buttons">
<button @click="startScanner" class="action-button">
<span>📷</span>
@@ -27,12 +25,10 @@
<input ref="fileInput" type="file" accept="image/*" @change="handleFileUpload" hidden />
</div>
</div>
<div id="qr-reader-container" v-show="isScannerActive">
<div id="qr-reader"></div>
<button @click="stopScanner" class="stop-button">Cancel</button>
</div>
<router-link to="/worker/history" class="history-link">View My Clock History </router-link>
</div>
</template>
@@ -40,43 +36,73 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { Html5Qrcode } from 'html5-qrcode'
import { useRouter } from 'vue-router'
let html5QrCode = null
const fileInput = ref(null)
const router = useRouter()
const isClockedIn = ref(false)
const isScannerActive = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const userId = ref(1) // In a real app, this would be from a store after login
// --- 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'))
const fetchCurrentStatus = async () => {
try {
const response = await fetch(`http://localhost:3000/api/worker/status/${userId.value}`)
const response = await fetch(`http://localhost:3000/api/worker/status/${userId}`)
if (!response.ok) return
const lastEvent = await response.json()
isClockedIn.value = lastEvent.eventType === 'clock_in'
} catch (error) {
} catch {
errorMessage.value = 'Could not verify current status from server.'
}
}
onMounted(() => {
if (!userId) {
router.push('/') // Redirect to login if no userId
return
}
fetchCurrentStatus()
})
onBeforeUnmount(() => {
if (html5QrCode && html5QrCode.isScanning) {
stopScanner()
}
})
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
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 }),
})
const data = await response.json()
if (response.ok) {
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${new Date().toLocaleTimeString()}`
} else {
errorMessage.value = `Error: ${data.message}`
}
} catch {
errorMessage.value = 'Failed to connect to the server.'
}
}
// Other functions (startScanner, stopScanner, etc.) remain unchanged
const clearMessages = () => {
errorMessage.value = ''
successMessage.value = ''
}
const startScanner = () => {
isScannerActive.value = true
clearMessages()
@@ -91,16 +117,13 @@ const startScanner = () => {
})
}, 100)
}
const stopScanner = () => {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().catch((err) => console.error('Failed to stop scanner', err))
}
isScannerActive.value = false
}
const triggerFileUpload = () => fileInput.value.click()
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (!file) return
@@ -114,11 +137,9 @@ const handleFileUpload = (event) => {
.catch((err) => onScanFailure(`Error scanning file: ${err}`))
}, 100)
}
const onScanSuccess = (decodedText) => {
successMessage.value = `QR Code detected. Getting location...`
stopScanner()
if (!navigator.geolocation) {
errorMessage.value = 'Geolocation is not supported by your browser.'
return
@@ -129,31 +150,10 @@ const onScanSuccess = (decodedText) => {
(errorMessage.value = 'Unable to retrieve your location. Please enable location services.'),
)
}
const onScanFailure = (error) => {
errorMessage.value = error
isScannerActive.value = false
}
const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
const eventType = isClockedIn.value ? 'clock_out' : 'clock_in'
try {
const response = await fetch('http://localhost:3000/api/clock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: userId.value, eventType, qrCodeValue, latitude, longitude }),
})
const data = await response.json()
if (response.ok) {
isClockedIn.value = !isClockedIn.value
successMessage.value = `Successfully clocked ${eventType.replace('_', ' ')} at ${new Date().toLocaleTimeString()}`
} else {
errorMessage.value = `Error: ${data.message}`
}
} catch (err) {
errorMessage.value = 'Failed to connect to the server.'
}
}
</script>
<style scoped>
@@ -178,7 +178,7 @@ const sendClockEvent = async (qrCodeValue, latitude, longitude) => {
background-color: var(--c-success);
}
.status-card.clocked-out {
background-color: var(--c-danger);
background-color: var(--c-secondary);
}
.status-card .status-icon {
font-size: 2.5rem;
+16 -6
View File
@@ -17,15 +17,17 @@
</tr>
<tr v-for="event in clockHistory" :key="event.id">
<td>
<span class="event-type" :class="event.eventType">{{
event.eventType.replace('_', ' ')
<span class="event-type" :class="event.event_type">{{
event.event_type.replace('_', ' ')
}}</span>
</td>
<td>{{ new Date(event.timestamp).toLocaleString() }}</td>
<td>{{ event.qrCodeUsedName || event.qrCodeUsedId }}</td>
<td>{{ event.qrCodeUsedName }}</td>
<td>
{{
event.latitude ? `${event.latitude.toFixed(4)}, ${event.longitude.toFixed(4)}` : 'N/A'
event.latitude
? `${Number(event.latitude).toFixed(4)}, ${Number(event.longitude).toFixed(4)}`
: 'N/A'
}}
</td>
</tr>
@@ -36,13 +38,21 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const clockHistory = ref([])
const userId = ref(1) // In a real app, get this from a store
// --- ALIGNMENT CHANGE ---
const userId = sessionStorage.getItem('userId')
onMounted(async () => {
if (!userId) {
router.push('/')
return
}
try {
const response = await fetch(`http://localhost:3000/api/worker/clock-history/${userId.value}`)
const response = await fetch(`http://localhost:3000/api/worker/clock-history/${userId}`)
if (response.ok) {
clockHistory.value = await response.json()
}