Axion/server/src/routes/timeclock.js
2025-12-07 19:37:52 -04:00

531 lines
17 KiB
JavaScript

import express from 'express';
import bcrypt from 'bcryptjs';
import { authenticateToken } from '../middleware/auth.js';
import db from '../database/db.js';
import { v4 as uuidv4 } from 'uuid';
const router = express.Router();
// Get all employees with current status (PUBLIC - no auth required)
router.get('/status', async (req, res) => {
try {
const now = new Date();
const today = now.toISOString().split('T')[0];
const currentTime = now.toTimeString().split(' ')[0].substring(0, 5); // HH:mm format
// Get all active employees with their current status
const employees = await db.allAsync(`
SELECT
u.id,
u.name,
u.email,
e.job_title,
s.start_time as scheduled_start,
s.end_time as scheduled_end
FROM users u
LEFT JOIN employees e ON u.id = e.user_id
LEFT JOIN shifts s ON u.id = s.employee_id AND s.date = ?
WHERE e.status = 'Active' AND u.role != 'admin'
ORDER BY u.name
`, today);
// Get current timecard status for each employee
const statuses = await Promise.all(employees.map(async (emp) => {
// Get ALL timecards for today (to show all clocked-in periods)
const timecards = await db.allAsync(`
SELECT clock_in, clock_out, break_start, break_end, status
FROM timecards
WHERE employee_id = ? AND date = ?
ORDER BY created_at ASC
`, emp.id, today);
// Get the most recent timecard for current status
const timecard = timecards.length > 0 ? timecards[timecards.length - 1] : null;
let currentStatus = 'clocked_out';
let clockInTime = null;
let clockOutTime = null;
let breakStartTime = null;
let isLate = false;
let leftEarly = false;
if (timecard) {
clockInTime = timecard.clock_in;
clockOutTime = timecard.clock_out;
// Status is only "clocked_in" or "on_break" if they clocked in but haven't clocked out yet
if (timecard.clock_in && !timecard.clock_out) {
if (timecard.break_start && !timecard.break_end) {
currentStatus = 'on_break';
breakStartTime = timecard.break_start;
} else {
currentStatus = 'clocked_in';
}
} else {
// If they've clocked out, status is clocked_out (even if they clocked in earlier)
currentStatus = 'clocked_out';
}
// Check if late (clocked in after scheduled start)
if (emp.scheduled_start && clockInTime) {
const scheduledTime = emp.scheduled_start;
const clockInTimeOnly = clockInTime.includes(' ')
? clockInTime.split(' ')[1]?.substring(0, 5)
: clockInTime.substring(0, 5);
if (clockInTimeOnly && clockInTimeOnly > scheduledTime) {
isLate = true;
}
}
// Check if left early (clocked out before scheduled end)
if (emp.scheduled_end && timecard.clock_out) {
const scheduledEndTime = emp.scheduled_end;
const clockOutTimeOnly = timecard.clock_out.includes(' ')
? timecard.clock_out.split(' ')[1]?.substring(0, 5)
: timecard.clock_out.substring(0, 5);
if (clockOutTimeOnly && clockOutTimeOnly < scheduledEndTime) {
leftEarly = true;
}
}
}
// Format times to HH:mm in LOCAL timezone
// Helper function to extract HH:mm from datetime, converting to local time
const extractTime = (datetime) => {
if (!datetime) return null;
try {
// Parse the datetime string (could be ISO format with Z or SQLite datetime)
// new Date() automatically converts UTC to local timezone
const date = new Date(datetime);
// Check if date is valid
if (isNaN(date.getTime())) {
console.error('Invalid date:', datetime);
return null;
}
// Get local time hours and minutes (getHours/getMinutes return local time)
const hours = date.getHours();
const minutes = date.getMinutes();
// Format as HH:mm (24-hour format)
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
} catch (error) {
console.error('Error parsing datetime:', datetime, error);
return null;
}
};
const formattedClockIn = extractTime(clockInTime);
const formattedClockOut = extractTime(clockOutTime);
const formattedBreakStart = extractTime(breakStartTime);
// Extract all clocked-in periods from all timecards
const clockedInPeriods = timecards
.filter(tc => tc.clock_in) // Only timecards with clock_in
.map(tc => ({
clockIn: extractTime(tc.clock_in),
clockOut: extractTime(tc.clock_out),
}))
.filter(period => period.clockIn); // Only include periods with valid clock-in time
return {
id: emp.id,
name: emp.name,
email: emp.email,
currentStatus,
clockInTime: formattedClockIn,
clockOutTime: formattedClockOut,
breakStartTime: formattedBreakStart,
scheduledStart: emp.scheduled_start,
scheduledEnd: emp.scheduled_end,
isLate,
leftEarly,
clockedInPeriods, // Include all periods for visualization
};
}));
res.json(statuses);
} catch (error) {
console.error('Error fetching employee status:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Clock in/out action (PUBLIC - password only)
router.post('/action', async (req, res) => {
try {
const { employeeId, password, action } = req.body;
if (!employeeId || !password || !action) {
return res.status(400).json({ error: 'Employee ID, password, and action are required' });
}
if (!['clock_in', 'clock_out', 'break_start', 'break_end'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
// Verify password
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', employeeId);
if (!user) {
return res.status(404).json({ error: 'Employee not found' });
}
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid password' });
}
const now = new Date();
const today = now.toISOString().split('T')[0];
const currentDateTime = now.toISOString();
// Get or create today's timecard
let timecard = await db.getAsync(`
SELECT * FROM timecards
WHERE employee_id = ? AND date = ?
ORDER BY created_at DESC
LIMIT 1
`, employeeId, today);
if (!timecard) {
// Create new timecard
const timecardId = uuidv4();
await db.runAsync(`
INSERT INTO timecards (id, employee_id, date, status)
VALUES (?, ?, ?, 'pending')
`, timecardId, employeeId, today);
timecard = { id: timecardId, employee_id: employeeId, date: today };
}
// Get scheduled shift for today
const shift = await db.getAsync(`
SELECT start_time, end_time FROM shifts
WHERE employee_id = ? AND date = ?
LIMIT 1
`, employeeId, today);
// Perform action
if (action === 'clock_in') {
// Allow clock in if they haven't clocked in today, OR if they've already clocked out
if (timecard.clock_in && !timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked in. Please clock out first.' });
}
// If they clocked out earlier today, create a new timecard entry for a new shift
if (timecard.clock_out) {
const newTimecardId = uuidv4();
await db.runAsync(`
INSERT INTO timecards (id, employee_id, date, status)
VALUES (?, ?, ?, 'pending')
`, newTimecardId, employeeId, today);
timecard = { id: newTimecardId, employee_id: employeeId, date: today, clock_in: null, clock_out: null };
}
await db.runAsync(`
UPDATE timecards
SET clock_in = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
// Check if late
if (shift && shift.start_time) {
const scheduledTime = new Date(`${today}T${shift.start_time}`);
if (now > scheduledTime) {
// Mark as late in notes
await db.runAsync(`
UPDATE timecards
SET notes = 'Late arrival', updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, timecard.id);
}
}
} else if (action === 'clock_out') {
if (!timecard.clock_in) {
return res.status(400).json({ error: 'Must clock in first' });
}
if (timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked out' });
}
// Calculate hours
const clockIn = new Date(timecard.clock_in);
const clockOut = now;
const diffMs = clockOut - clockIn;
const diffHours = diffMs / (1000 * 60 * 60);
let breakTime = 0;
if (timecard.break_start && timecard.break_end) {
const breakStart = new Date(timecard.break_start);
const breakEnd = new Date(timecard.break_end);
breakTime = (breakEnd - breakStart) / (1000 * 60 * 60);
}
const totalHours = diffHours - breakTime;
await db.runAsync(`
UPDATE timecards
SET clock_out = ?,
hours = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, totalHours, timecard.id);
// Check if left early
if (shift && shift.end_time) {
const scheduledEnd = new Date(`${today}T${shift.end_time}`);
if (now < scheduledEnd) {
const currentNotes = timecard.notes || '';
await db.runAsync(`
UPDATE timecards
SET notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentNotes ? `${currentNotes}; Left early` : 'Left early', timecard.id);
}
}
} else if (action === 'break_start') {
if (!timecard.clock_in) {
return res.status(400).json({ error: 'Must clock in first' });
}
if (timecard.break_start && !timecard.break_end) {
return res.status(400).json({ error: 'Already on break' });
}
await db.runAsync(`
UPDATE timecards
SET break_start = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
} else if (action === 'break_end') {
if (!timecard.break_start) {
return res.status(400).json({ error: 'Not on break' });
}
if (timecard.break_end) {
return res.status(400).json({ error: 'Break already ended' });
}
await db.runAsync(`
UPDATE timecards
SET break_end = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
}
res.json({
success: true,
message: `Successfully ${action.replace('_', ' ')}`,
timecardId: timecard.id
});
} catch (error) {
console.error('Error processing clock action:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Authenticated clock action (uses JWT instead of password)
router.post('/action/authenticated', authenticateToken, async (req, res) => {
try {
const { action } = req.body;
const employeeId = req.user.id; // Use authenticated user's ID
if (!action) {
return res.status(400).json({ error: 'Action is required' });
}
if (!['clock_in', 'clock_out', 'break_start', 'break_end'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
// Verify user is an employee (not admin)
const user = await db.getAsync(`
SELECT u.*, e.status as employee_status
FROM users u
LEFT JOIN employees e ON u.id = e.user_id
WHERE u.id = ?
`, employeeId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
if (user.role === 'admin') {
return res.status(403).json({ error: 'Admin users cannot clock in/out' });
}
const now = new Date();
const today = now.toISOString().split('T')[0];
const currentDateTime = now.toISOString();
// Get or create today's timecard
// For clock_out and break actions, we need the most recent timecard that's not completed
let timecard = await db.getAsync(`
SELECT * FROM timecards
WHERE employee_id = ? AND date = ?
ORDER BY created_at DESC
LIMIT 1
`, employeeId, today);
// For clock_in, create new timecard if needed or if previous one is completed
if (action === 'clock_in') {
if (!timecard || timecard.clock_out) {
// Create new timecard for new shift
const timecardId = uuidv4();
await db.runAsync(`
INSERT INTO timecards (id, employee_id, date, status)
VALUES (?, ?, ?, 'pending')
`, timecardId, employeeId, today);
timecard = { id: timecardId, employee_id: employeeId, date: today, clock_in: null, clock_out: null, break_start: null, break_end: null };
} else if (timecard.clock_in && !timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked in. Please clock out first.' });
}
} else {
// For other actions, timecard must exist
if (!timecard) {
return res.status(400).json({ error: 'No timecard found. Please clock in first.' });
}
}
// Get scheduled shift for today
const shift = await db.getAsync(`
SELECT start_time, end_time FROM shifts
WHERE employee_id = ? AND date = ?
LIMIT 1
`, employeeId, today);
// Perform action
if (action === 'clock_in') {
await db.runAsync(`
UPDATE timecards
SET clock_in = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
// Check if late
if (shift && shift.start_time) {
const scheduledTime = new Date(`${today}T${shift.start_time}`);
if (now > scheduledTime) {
await db.runAsync(`
UPDATE timecards
SET notes = 'Late arrival', updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, timecard.id);
}
}
} else if (action === 'clock_out') {
if (!timecard.clock_in) {
return res.status(400).json({ error: 'Must clock in first' });
}
if (timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked out' });
}
// Calculate hours
const clockIn = new Date(timecard.clock_in);
const clockOut = now;
const diffMs = clockOut - clockIn;
const diffHours = diffMs / (1000 * 60 * 60);
let breakTime = 0;
if (timecard.break_start && timecard.break_end) {
const breakStart = new Date(timecard.break_start);
const breakEnd = new Date(timecard.break_end);
breakTime = (breakEnd - breakStart) / (1000 * 60 * 60);
}
const totalHours = diffHours - breakTime;
await db.runAsync(`
UPDATE timecards
SET clock_out = ?,
hours = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, totalHours, timecard.id);
// Check if left early
if (shift && shift.end_time) {
const scheduledEnd = new Date(`${today}T${shift.end_time}`);
if (clockOut < scheduledEnd) {
const currentNotes = timecard.notes || '';
await db.runAsync(`
UPDATE timecards
SET notes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentNotes ? `${currentNotes}; Left early` : 'Left early', timecard.id);
}
}
} else if (action === 'break_start') {
if (!timecard.clock_in) {
return res.status(400).json({ error: 'Must clock in first' });
}
if (timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked out' });
}
if (timecard.break_start && !timecard.break_end) {
return res.status(400).json({ error: 'Already on break' });
}
await db.runAsync(`
UPDATE timecards
SET break_start = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
} else if (action === 'break_end') {
if (!timecard.clock_in) {
return res.status(400).json({ error: 'Must clock in first' });
}
if (timecard.clock_out) {
return res.status(400).json({ error: 'Already clocked out' });
}
if (!timecard.break_start) {
return res.status(400).json({ error: 'Not on break' });
}
if (timecard.break_end) {
return res.status(400).json({ error: 'Break already ended' });
}
await db.runAsync(`
UPDATE timecards
SET break_end = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, currentDateTime, timecard.id);
}
res.json({
message: 'Action completed successfully',
action,
timestamp: currentDateTime
});
} catch (error) {
console.error('Clock action error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
export default router;