531 lines
17 KiB
JavaScript
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;
|