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
+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()