diff --git a/server/src/database/schema.sql b/server/src/database/schema.sql index 7031042..e7e5eec 100644 --- a/server/src/database/schema.sql +++ b/server/src/database/schema.sql @@ -1,20 +1,19 @@ --- Axion HR/Payroll System Database Schema - --- Users table (for authentication and user management) +-- Users table CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT NOT NULL, - role TEXT NOT NULL CHECK(role IN ('employee', 'manager', 'hr', 'payroll', 'admin')), + role TEXT NOT NULL CHECK(role IN ('admin', 'hr', 'payroll', 'manager', 'employee')), + must_change_password INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); --- Employees table (extended employee information) +-- Employees table CREATE TABLE IF NOT EXISTS employees ( id TEXT PRIMARY KEY, - user_id TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL UNIQUE, job_title TEXT, department TEXT, manager_id TEXT, @@ -37,10 +36,9 @@ CREATE TABLE IF NOT EXISTS timecards ( clock_out DATETIME, break_start DATETIME, break_end DATETIME, - total_hours REAL DEFAULT 0, - overtime_hours REAL DEFAULT 0, - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'flagged')), + hours REAL DEFAULT 0, notes TEXT, + status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'approved', 'rejected')), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE @@ -51,9 +49,8 @@ CREATE TABLE IF NOT EXISTS shifts ( id TEXT PRIMARY KEY, employee_id TEXT NOT NULL, date DATE NOT NULL, - start_time TIME NOT NULL, - end_time TIME NOT NULL, - role TEXT, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, location TEXT, notes TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -66,11 +63,12 @@ CREATE TABLE IF NOT EXISTS disciplinary_actions ( id TEXT PRIMARY KEY, employee_id TEXT NOT NULL, reported_by TEXT NOT NULL, - incident_date DATE NOT NULL, - details TEXT NOT NULL, - severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high', 'critical')), - status TEXT DEFAULT 'draft' CHECK(status IN ('draft', 'pending_approval', 'finalized')), - document_url TEXT, + date DATE NOT NULL, + type TEXT NOT NULL CHECK(type IN ('verbal', 'written', 'suspension', 'termination')), + severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high')), + description TEXT NOT NULL, + outcome TEXT, + status TEXT DEFAULT 'open' CHECK(status IN ('open', 'resolved', 'closed')), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE, @@ -179,4 +177,3 @@ CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_id); CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at); CREATE INDEX IF NOT EXISTS idx_employees_user ON employees(user_id); CREATE INDEX IF NOT EXISTS idx_payroll_line_items_run ON payroll_line_items(payroll_run_id); - diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 3d1eba2..23c3cdb 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -1,6 +1,7 @@ import express from 'express'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import { authenticateToken } from '../middleware/auth.js'; import db from '../database/db.js'; const router = express.Router(); @@ -39,7 +40,8 @@ router.post('/login', async (req, res) => { id: user.id, name: user.name, email: user.email, - role: user.role + role: user.role, + mustChangePassword: user.must_change_password === 1 } }); } catch (error) { @@ -59,16 +61,68 @@ router.get('/me', async (req, res) => { } const decoded = jwt.verify(token, JWT_SECRET); - const user = await db.getAsync('SELECT id, email, name, role FROM users WHERE id = ?', decoded.id); + const user = await db.getAsync('SELECT id, email, name, role, must_change_password FROM users WHERE id = ?', decoded.id); if (!user) { return res.status(404).json({ error: 'User not found' }); } - res.json({ user }); + res.json({ + user: { + ...user, + mustChangePassword: user.must_change_password === 1 + } + }); } catch (error) { res.status(401).json({ error: 'Invalid token' }); } }); +// Change password +router.post('/change-password', authenticateToken, async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + const userId = req.user.id; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: 'Current password and new password are required' }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ error: 'New password must be at least 6 characters long' }); + } + + // Get user with password hash + const user = await db.getAsync('SELECT * FROM users WHERE id = ?', userId); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Verify current password + const isValid = await bcrypt.compare(currentPassword, user.password_hash); + + if (!isValid) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Hash new password + const newPasswordHash = await bcrypt.hash(newPassword, 10); + + // Update password and clear must_change_password flag + await db.runAsync(` + UPDATE users + SET password_hash = ?, + must_change_password = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, newPasswordHash, userId); + + res.json({ message: 'Password changed successfully' }); + } catch (error) { + console.error('Change password error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + export default router; diff --git a/server/src/routes/employees.js b/server/src/routes/employees.js new file mode 100644 index 0000000..6a28f4e --- /dev/null +++ b/server/src/routes/employees.js @@ -0,0 +1,129 @@ +import express from 'express'; +import { authenticateToken, requireRole } from '../middleware/auth.js'; +import db from '../database/db.js'; +import { v4 as uuidv4 } from 'uuid'; + +const router = express.Router(); + +// Get all employees +router.get('/', authenticateToken, async (req, res) => { + try { + const employees = await db.allAsync(` + SELECT + u.id, + u.name, + u.email, + e.job_title as jobTitle, + e.department, + e.status, + e.phone, + e.address, + e.hire_date as hireDate, + e.manager_id, + m.name as manager + FROM users u + INNER JOIN employees e ON u.id = e.user_id + LEFT JOIN users m ON e.manager_id = m.id + WHERE u.role != 'admin' + ORDER BY u.name + `); + + res.json(employees); + } catch (error) { + console.error('Error fetching employees:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get employee by ID +router.get('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + + const employee = await db.getAsync(` + SELECT + u.id, + u.name, + u.email, + e.job_title as jobTitle, + e.department, + e.status, + e.phone, + e.address, + e.hire_date as hireDate, + e.manager_id, + m.name as manager + FROM users u + INNER JOIN employees e ON u.id = e.user_id + LEFT JOIN users m ON e.manager_id = m.id + WHERE u.id = ? AND u.role != 'admin' + `, id); + + if (!employee) { + return res.status(404).json({ error: 'Employee not found' }); + } + + res.json(employee); + } catch (error) { + console.error('Error fetching employee:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update employee +router.put('/:id', authenticateToken, requireRole('admin', 'hr'), async (req, res) => { + try { + const { id } = req.params; + const { name, email, jobTitle, department, phone, address, status, managerId } = req.body; + + // Update user + if (name || email) { + await db.runAsync(` + UPDATE users + SET name = COALESCE(?, name), + email = COALESCE(?, email), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `, name || null, email || null, id); + } + + // Update employee record + await db.runAsync(` + UPDATE employees + SET job_title = COALESCE(?, job_title), + department = COALESCE(?, department), + phone = COALESCE(?, phone), + address = COALESCE(?, address), + status = COALESCE(?, status), + manager_id = ?, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `, jobTitle || null, department || null, phone || null, address || null, status || null, managerId || null, id); + + res.json({ message: 'Employee updated successfully' }); + } catch (error) { + console.error('Error updating employee:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete employee (soft delete by setting status to Inactive) +router.delete('/:id', authenticateToken, requireRole('admin', 'hr'), async (req, res) => { + try { + const { id } = req.params; + + await db.runAsync(` + UPDATE employees + SET status = 'Inactive', updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + `, id); + + res.json({ message: 'Employee deleted successfully' }); + } catch (error) { + console.error('Error deleting employee:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; + diff --git a/server/src/routes/shifts.js b/server/src/routes/shifts.js new file mode 100644 index 0000000..644a841 --- /dev/null +++ b/server/src/routes/shifts.js @@ -0,0 +1,306 @@ +import express from 'express'; +import db from '../database/db.js'; +import { authenticateToken, requireRole } from '../middleware/auth.js'; +import { v4 as uuidv4 } from 'uuid'; + +const router = express.Router(); + +// Get shifts for a date range +router.get('/', authenticateToken, async (req, res) => { + try { + const { startDate, endDate, employeeId } = req.query; + + let query = ` + SELECT + s.id, + s.employee_id, + s.date, + s.start_time, + s.end_time, + s.location, + s.notes, + u.name as employee_name + FROM shifts s + JOIN users u ON s.employee_id = u.id + WHERE 1=1 + `; + const params = []; + + if (startDate) { + query += ' AND s.date >= ?'; + params.push(startDate); + } + + if (endDate) { + query += ' AND s.date <= ?'; + params.push(endDate); + } + + if (employeeId) { + query += ' AND s.employee_id = ?'; + params.push(employeeId); + } + + // If not admin/hr/manager, only show own shifts + if (!['admin', 'hr', 'manager'].includes(req.user.role)) { + query += ' AND s.employee_id = ?'; + params.push(req.user.id); + } + + query += ' ORDER BY s.date, s.start_time'; + + const shifts = await db.allAsync(query, ...params); + + res.json(shifts); + } catch (error) { + console.error('Error fetching shifts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Get shift by ID +router.get('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + + const shift = await db.getAsync(` + SELECT + s.*, + u.name as employee_name + FROM shifts s + JOIN users u ON s.employee_id = u.id + WHERE s.id = ? + `, id); + + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + + // Check permissions + if (shift.employee_id !== req.user.id && !['admin', 'hr', 'manager'].includes(req.user.role)) { + return res.status(403).json({ error: 'Access denied' }); + } + + res.json(shift); + } catch (error) { + console.error('Error fetching shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Create shift (admin, hr, manager only) +router.post('/', authenticateToken, requireRole('admin', 'hr', 'manager'), async (req, res) => { + try { + const { employeeId, date, startTime, endTime, location, notes } = req.body; + + if (!employeeId || !date || !startTime || !endTime) { + return res.status(400).json({ error: 'Employee ID, date, start time, and end time are required' }); + } + + // Validate date format + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' }); + } + + // Validate time format (HH:MM) + if (!/^\d{2}:\d{2}$/.test(startTime) || !/^\d{2}:\d{2}$/.test(endTime)) { + return res.status(400).json({ error: 'Invalid time format. Use HH:MM (24-hour format)' }); + } + + // Check if employee exists + const employee = await db.getAsync('SELECT id FROM users WHERE id = ?', employeeId); + if (!employee) { + return res.status(404).json({ error: 'Employee not found' }); + } + + // Check if shift already exists for this employee and date + const existing = await db.getAsync( + 'SELECT id FROM shifts WHERE employee_id = ? AND date = ?', + employeeId, + date + ); + + if (existing) { + return res.status(400).json({ error: 'Shift already exists for this employee on this date' }); + } + + const id = uuidv4(); + await db.runAsync(` + INSERT INTO shifts (id, employee_id, date, start_time, end_time, location, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, employeeId, date, startTime, endTime, location || null, notes || null); + + const newShift = await db.getAsync(` + SELECT + s.*, + u.name as employee_name + FROM shifts s + JOIN users u ON s.employee_id = u.id + WHERE s.id = ? + `, id); + + res.status(201).json(newShift); + } catch (error) { + console.error('Error creating shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Update shift (admin, hr, manager only) +router.put('/:id', authenticateToken, requireRole('admin', 'hr', 'manager'), async (req, res) => { + try { + const { id } = req.params; + const { date, startTime, endTime, location, notes } = req.body; + + // Check if shift exists + const shift = await db.getAsync('SELECT * FROM shifts WHERE id = ?', id); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + + // Validate date format if provided + if (date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' }); + } + + // Validate time format if provided + if (startTime && !/^\d{2}:\d{2}$/.test(startTime)) { + return res.status(400).json({ error: 'Invalid start time format. Use HH:MM (24-hour format)' }); + } + + if (endTime && !/^\d{2}:\d{2}$/.test(endTime)) { + return res.status(400).json({ error: 'Invalid end time format. Use HH:MM (24-hour format)' }); + } + + // Build update query dynamically + const updates = []; + const params = []; + + if (date !== undefined) { + updates.push('date = ?'); + params.push(date); + } + if (startTime !== undefined) { + updates.push('start_time = ?'); + params.push(startTime); + } + if (endTime !== undefined) { + updates.push('end_time = ?'); + params.push(endTime); + } + if (location !== undefined) { + updates.push('location = ?'); + params.push(location); + } + if (notes !== undefined) { + updates.push('notes = ?'); + params.push(notes); + } + + if (updates.length === 0) { + return res.status(400).json({ error: 'No fields to update' }); + } + + updates.push('updated_at = CURRENT_TIMESTAMP'); + params.push(id); + + await db.runAsync(` + UPDATE shifts + SET ${updates.join(', ')} + WHERE id = ? + `, ...params); + + const updatedShift = await db.getAsync(` + SELECT + s.*, + u.name as employee_name + FROM shifts s + JOIN users u ON s.employee_id = u.id + WHERE s.id = ? + `, id); + + res.json(updatedShift); + } catch (error) { + console.error('Error updating shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Delete shift (admin, hr, manager only) +router.delete('/:id', authenticateToken, requireRole('admin', 'hr', 'manager'), async (req, res) => { + try { + const { id } = req.params; + + const shift = await db.getAsync('SELECT id FROM shifts WHERE id = ?', id); + if (!shift) { + return res.status(404).json({ error: 'Shift not found' }); + } + + await db.runAsync('DELETE FROM shifts WHERE id = ?', id); + + res.json({ message: 'Shift deleted successfully' }); + } catch (error) { + console.error('Error deleting shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Bulk create shifts (admin, hr, manager only) +router.post('/bulk', authenticateToken, requireRole('admin', 'hr', 'manager'), async (req, res) => { + try { + const { shifts } = req.body; + + if (!Array.isArray(shifts) || shifts.length === 0) { + return res.status(400).json({ error: 'Shifts array is required' }); + } + + const results = []; + const errors = []; + + for (const shift of shifts) { + const { employeeId, date, startTime, endTime, location, notes } = shift; + + if (!employeeId || !date || !startTime || !endTime) { + errors.push({ shift, error: 'Missing required fields' }); + continue; + } + + try { + // Check if shift already exists + const existing = await db.getAsync( + 'SELECT id FROM shifts WHERE employee_id = ? AND date = ?', + employeeId, + date + ); + + if (existing) { + errors.push({ shift, error: 'Shift already exists for this employee on this date' }); + continue; + } + + const id = uuidv4(); + await db.runAsync(` + INSERT INTO shifts (id, employee_id, date, start_time, end_time, location, notes) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, id, employeeId, date, startTime, endTime, location || null, notes || null); + + results.push({ id, ...shift }); + } catch (error) { + errors.push({ shift, error: error.message }); + } + } + + res.status(201).json({ + created: results.length, + errors: errors.length, + results, + errors: errors.length > 0 ? errors : undefined + }); + } catch (error) { + console.error('Error bulk creating shifts:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; + diff --git a/server/src/routes/timeclock.js b/server/src/routes/timeclock.js index b4a4e90..dd1eec4 100644 --- a/server/src/routes/timeclock.js +++ b/server/src/routes/timeclock.js @@ -1,5 +1,6 @@ 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'; @@ -30,13 +31,16 @@ router.get('/status', async (req, res) => { // Get current timecard status for each employee const statuses = await Promise.all(employees.map(async (emp) => { - const timecard = await db.getAsync(` + // 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 DESC - LIMIT 1 + 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; @@ -85,24 +89,46 @@ router.get('/status', async (req, res) => { } } - // Format times to HH:mm - let formattedClockIn = null; - if (clockInTime) { - const timePart = clockInTime.includes(' ') ? clockInTime.split(' ')[1] : clockInTime; - formattedClockIn = timePart.substring(0, 5); // HH:mm - } + // 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; + } + }; - let formattedClockOut = null; - if (clockOutTime) { - const timePart = clockOutTime.includes(' ') ? clockOutTime.split(' ')[1] : clockOutTime; - formattedClockOut = timePart.substring(0, 5); - } - - let formattedBreakStart = null; - if (breakStartTime) { - const timePart = breakStartTime.includes(' ') ? breakStartTime.split(' ')[1] : breakStartTime; - formattedBreakStart = timePart.substring(0, 5); - } + 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, @@ -116,6 +142,7 @@ router.get('/status', async (req, res) => { scheduledEnd: emp.scheduled_end, isLate, leftEarly, + clockedInPeriods, // Include all periods for visualization }; })); @@ -240,16 +267,14 @@ router.post('/action', async (req, res) => { } const totalHours = diffHours - breakTime; - const overtimeHours = totalHours > 8 ? totalHours - 8 : 0; await db.runAsync(` UPDATE timecards SET clock_out = ?, - total_hours = ?, - overtime_hours = ?, + hours = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `, currentDateTime, totalHours, overtimeHours, timecard.id); + `, currentDateTime, totalHours, timecard.id); // Check if left early if (shift && shift.end_time) { @@ -307,4 +332,199 @@ router.post('/action', async (req, res) => { } }); +// 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; diff --git a/server/src/routes/users.js b/server/src/routes/users.js index 8fc57a9..71a26f5 100644 --- a/server/src/routes/users.js +++ b/server/src/routes/users.js @@ -37,9 +37,11 @@ router.get('/:id', authenticateToken, async (req, res) => { const user = await db.getAsync(` SELECT u.id, u.email, u.name, u.role, u.created_at, e.job_title, e.department, e.manager_id, e.phone, e.address, - e.hire_date, e.status + e.hire_date, e.status, + m.name as manager FROM users u LEFT JOIN employees e ON u.id = e.user_id + LEFT JOIN users m ON e.manager_id = m.id WHERE u.id = ? `, id); @@ -67,10 +69,10 @@ router.post('/', authenticateToken, requireRole('admin'), async (req, res) => { const defaultPassword = 'password123'; // Should be changed on first login const passwordHash = bcrypt.hashSync(defaultPassword, 10); - // Insert user + // Insert user with must_change_password flag set to 1 for new users await db.runAsync(` - INSERT INTO users (id, email, password_hash, name, role) - VALUES (?, ?, ?, ?, ?) + INSERT INTO users (id, email, password_hash, name, role, must_change_password) + VALUES (?, ?, ?, ?, ?, 1) `, id, email, passwordHash, name, role); // Insert employee record if not admin @@ -92,27 +94,84 @@ router.post('/', authenticateToken, requireRole('admin'), async (req, res) => { }); // Update user -router.put('/:id', authenticateToken, requireRole('admin', 'hr'), async (req, res) => { +router.put('/:id', authenticateToken, async (req, res) => { try { const { id } = req.params; const { name, email, role, jobTitle, department, phone, address, status } = req.body; - // Update user - await db.runAsync(` - UPDATE users - SET name = ?, email = ?, role = ?, updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `, name, email, role, id); + // Users can only update their own profile unless admin/hr + if (req.user.id !== id && !['admin', 'hr'].includes(req.user.role)) { + return res.status(403).json({ error: 'Access denied' }); + } - // Update employee record + // Only admin/hr can change role + if (role && !['admin', 'hr'].includes(req.user.role)) { + return res.status(403).json({ error: 'Only admins can change user roles' }); + } + + // Update user - only update fields that are provided and allowed + const updateFields = []; + const updateValues = []; + + if (name !== undefined) { + updateFields.push('name = ?'); + updateValues.push(name); + } + if (email !== undefined) { + updateFields.push('email = ?'); + updateValues.push(email); + } + if (role !== undefined && ['admin', 'hr'].includes(req.user.role)) { + updateFields.push('role = ?'); + updateValues.push(role); + } + + if (updateFields.length > 0) { + updateFields.push('updated_at = CURRENT_TIMESTAMP'); + updateValues.push(id); + await db.runAsync(` + UPDATE users + SET ${updateFields.join(', ')} + WHERE id = ? + `, ...updateValues); + } + + // Update employee record if fields are provided const employee = await db.getAsync('SELECT id FROM employees WHERE user_id = ?', id); if (employee) { - await db.runAsync(` - UPDATE employees - SET job_title = ?, department = ?, phone = ?, address = ?, - status = ?, updated_at = CURRENT_TIMESTAMP - WHERE user_id = ? - `, jobTitle, department, phone, address, status, id); + const empUpdateFields = []; + const empUpdateValues = []; + + if (jobTitle !== undefined) { + empUpdateFields.push('job_title = ?'); + empUpdateValues.push(jobTitle); + } + if (department !== undefined) { + empUpdateFields.push('department = ?'); + empUpdateValues.push(department); + } + if (phone !== undefined) { + empUpdateFields.push('phone = ?'); + empUpdateValues.push(phone); + } + if (address !== undefined) { + empUpdateFields.push('address = ?'); + empUpdateValues.push(address); + } + if (status !== undefined && ['admin', 'hr'].includes(req.user.role)) { + empUpdateFields.push('status = ?'); + empUpdateValues.push(status); + } + + if (empUpdateFields.length > 0) { + empUpdateFields.push('updated_at = CURRENT_TIMESTAMP'); + empUpdateValues.push(id); + await db.runAsync(` + UPDATE employees + SET ${empUpdateFields.join(', ')} + WHERE user_id = ? + `, ...empUpdateValues); + } } res.json({ message: 'User updated successfully' }); diff --git a/server/src/server.js b/server/src/server.js index be7b3c2..b68f951 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -3,9 +3,11 @@ import cors from 'cors'; import dotenv from 'dotenv'; import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; +import employeeRoutes from './routes/employees.js'; import receiptRoutes from './routes/receipts.js'; import timecardRoutes from './routes/timecards.js'; import timeclockRoutes from './routes/timeclock.js'; +import shiftRoutes from './routes/shifts.js'; dotenv.config(); @@ -24,9 +26,11 @@ app.get('/health', (req, res) => { // Routes app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); +app.use('/api/employees', employeeRoutes); app.use('/api/receipts', receiptRoutes); app.use('/api/timecards', timecardRoutes); app.use('/api/timeclock', timeclockRoutes); +app.use('/api/shifts', shiftRoutes); // Error handling app.use((err, req, res, next) => { diff --git a/src/App.tsx b/src/App.tsx index 3d7407b..ce71cdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { Dashboard } from './pages/Dashboard'; import { ClockInOut } from './pages/ClockInOut'; import { Timecards } from './pages/Timecards'; import { Schedule } from './pages/Schedule'; +import { ScheduleManagement } from './pages/ScheduleManagement'; import { TeamTimecards } from './pages/TeamTimecards'; import { TeamSchedules } from './pages/TeamSchedules'; import { Receipts } from './pages/Receipts'; @@ -17,6 +18,9 @@ import { SystemSettings } from './pages/SystemSettings'; import { AuditLogs } from './pages/AuditLogs'; import { UserManagement } from './pages/UserManagement'; import { Placeholder } from './pages/Placeholder'; +import { ChangePassword } from './pages/ChangePassword'; +import { MyProfile } from './pages/MyProfile'; +import { Settings } from './pages/Settings'; import { ProtectedRoute } from './components/ProtectedRoute'; import { useAuth } from './context/AuthContext'; @@ -26,11 +30,34 @@ function LoginRoute() { } function AppRoutes() { + const { isAuthenticated, mustChangePassword } = useAuth(); return ( - } /> } /> + : + } + /> + + + + } + /> + + + + } + /> + } /> } /> + + + + } + /> , roles: ['manager'] }, { path: '/team-schedules', label: 'Team Schedules', icon: , roles: ['manager'] }, + { path: '/schedule-management', label: 'Schedule Management', icon: , roles: ['manager', 'hr', 'admin'] }, { path: '/approvals', label: 'Approvals', icon: , roles: ['manager'] }, { path: '/incident-report', label: 'Incident Report', icon: , roles: ['manager'] }, // HR items diff --git a/src/components/Layout/TopBar.tsx b/src/components/Layout/TopBar.tsx index f41f290..d80c9e1 100644 --- a/src/components/Layout/TopBar.tsx +++ b/src/components/Layout/TopBar.tsx @@ -4,17 +4,105 @@ import { Search, Bell, User, Settings, LogOut } from 'lucide-react'; import { useUser } from '../../context/UserContext'; import { useAuth } from '../../context/AuthContext'; +interface Notification { + id: string; + type: 'timecard' | 'schedule' | 'payroll' | 'general'; + title: string; + time: string; + path: string; +} + export const TopBar: React.FC = () => { const { user } = useUser(); const { logout } = useAuth(); const navigate = useNavigate(); const [showProfileMenu, setShowProfileMenu] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); + + // Initialize notifications from localStorage or use default + const getDefaultNotifications = (): Notification[] => { + const timecardPath = ['manager', 'hr', 'admin'].includes(user.role) ? '/team-timecards' : '/timecards'; + const schedulePath = ['manager', 'hr', 'admin'].includes(user.role) ? '/team-schedules' : '/schedule'; + + return [ + { + id: '1', + type: 'timecard', + title: 'New timecard submitted', + time: '2 hours ago', + path: timecardPath, + }, + { + id: '2', + type: 'schedule', + title: 'Schedule updated', + time: '5 hours ago', + path: schedulePath, + }, + { + id: '3', + type: 'payroll', + title: 'Payroll reminder', + time: '1 day ago', + path: '/payroll-runs', + }, + ]; + }; + + const [notifications, setNotifications] = useState(() => { + const saved = localStorage.getItem('notifications'); + if (saved) { + try { + const parsed = JSON.parse(saved); + // If saved notifications exist, use them; otherwise use defaults + return parsed.length > 0 ? parsed : getDefaultNotifications(); + } catch { + return getDefaultNotifications(); + } + } + return getDefaultNotifications(); + }); + + // Save notifications to localStorage whenever they change + React.useEffect(() => { + localStorage.setItem('notifications', JSON.stringify(notifications)); + }, [notifications]); const handleLogout = () => { logout(); navigate('/login'); }; + const handleNotificationClick = (path: string) => { + navigate(path); + setShowNotifications(false); + }; + + const handleDismissNotification = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent navigation when clicking dismiss + setNotifications(prev => prev.filter(n => n.id !== id)); + }; + + const handleClearAllNotifications = () => { + setNotifications([]); + }; + + // Close menus when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if (!target.closest('.notification-menu') && !target.closest('.notification-button')) { + setShowNotifications(false); + } + if (!target.closest('.profile-menu') && !target.closest('.profile-button')) { + setShowProfileMenu(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + return (
@@ -35,15 +123,94 @@ export const TopBar: React.FC = () => {
- +
+ + + {showNotifications && ( +
+
+

Notifications

+ {notifications.length > 0 && ( + + )} +
+
+ {notifications.length === 0 ? ( +
+ +

No notifications

+
+ ) : ( + notifications.map((notification) => { + const getColor = () => { + switch (notification.type) { + case 'timecard': + return 'bg-blue-500'; + case 'schedule': + return 'bg-green-500'; + case 'payroll': + return 'bg-yellow-500'; + default: + return 'bg-gray-500'; + } + }; + + return ( +
handleNotificationClick(notification.path)} + > +
+
+
+

{notification.title}

+

{notification.time}

+
+ +
+
+ ); + }) + )} +
+ {notifications.length > 0 && ( +
+ +
+ )} +
+ )} +
{showProfileMenu && ( -
- +
+ )} + +
+ + )} +
+
+
+ ); +}; + diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 5197c69..30f807c 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,12 +1,81 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Layout } from '../components/Layout/Layout'; import { useUser } from '../context/UserContext'; import { Clock, Calendar, FileText, AlertCircle, Users, CheckCircle, XCircle, Coffee, TrendingUp, DollarSign, Settings, Star } from 'lucide-react'; import { format } from 'date-fns'; +const API_URL = 'http://localhost:3001/api'; + export const Dashboard: React.FC = () => { - const { user, clockStatus } = useUser(); + const { user, clockStatus, setClockStatus } = useUser(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + // Fetch current clock status on mount + useEffect(() => { + fetchClockStatus(); + }, []); + + const fetchClockStatus = async () => { + try { + const response = await fetch(`${API_URL}/timeclock/status`); + + if (response.ok) { + const employees = await response.json(); + const currentEmployee = employees.find((emp: any) => emp.id === user.id); + if (currentEmployee) { + setClockStatus(currentEmployee.currentStatus); + } + } + } catch (error) { + console.error('Error fetching clock status:', error); + } + }; + + const handleClockAction = async (action: string) => { + setLoading(true); + setError(''); + + try { + const token = localStorage.getItem('token'); + const response = await fetch(`${API_URL}/timeclock/action/authenticated`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ action }), + }); + + const data = await response.json(); + + if (response.ok) { + // Update clock status based on action + if (action === 'clock_in') { + setClockStatus('clocked_in'); + } else if (action === 'clock_out') { + setClockStatus('clocked_out'); + } else if (action === 'break_start') { + setClockStatus('on_break'); + } else if (action === 'break_end') { + setClockStatus('clocked_in'); + } + + // Refresh status after a short delay + setTimeout(() => { + fetchClockStatus(); + }, 500); + } else { + setError(data.error || 'Action failed'); + } + } catch (error) { + console.error('Clock action error:', error); + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + }; const renderEmployeeView = () => (
@@ -15,6 +84,11 @@ export const Dashboard: React.FC = () => {

Current Status

+ {error && ( +
+ {error} +
+ )}
{ clockStatus === 'on_break' ? 'ON BREAK' : 'CLOCKED OUT'}
- {clockStatus === 'clocked_in' && ( - )} @@ -193,10 +285,13 @@ export const Dashboard: React.FC = () => {

Create Incident Report

Report a workplace incident

- +
); @@ -253,10 +348,13 @@ export const Dashboard: React.FC = () => {

Start Disciplinary Action

Initiate a disciplinary process

- + +

Manage Schedules

+

Create and manage employee schedules

+ {/* Alerts */} @@ -364,7 +462,7 @@ export const Dashboard: React.FC = () => { {/* Quick Actions */} -
+

System Settings

@@ -375,6 +473,11 @@ export const Dashboard: React.FC = () => {

User Provisioning

Manage user accounts

+ + +

Schedule Management

+

Create and manage employee schedules

+
); diff --git a/src/pages/Employees.tsx b/src/pages/Employees.tsx index 9cd71e9..6e9a0ae 100644 --- a/src/pages/Employees.tsx +++ b/src/pages/Employees.tsx @@ -1,6 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; import { Layout } from '../components/Layout/Layout'; -import { Users, Edit, Eye, MoreVertical, Search, Filter, Trash2, Save, X } from 'lucide-react'; +import { Users, Edit, Eye, MoreVertical, Search, Filter, Trash2, Save, X, Plus } from 'lucide-react'; +import { useAuth } from '../context/AuthContext'; +import { UserRole } from '../types'; + +const API_URL = 'http://localhost:3001/api'; interface Employee { id: string; @@ -8,7 +12,7 @@ interface Employee { jobTitle: string; department: string; status: string; - manager: string; + manager?: string; email?: string; phone?: string; address?: string; @@ -16,16 +20,63 @@ interface Employee { } export const Employees: React.FC = () => { + const { user: currentUser } = useAuth(); const [selectedEmployee, setSelectedEmployee] = useState(null); const [editingEmployee, setEditingEmployee] = useState(null); const [showEditForm, setShowEditForm] = useState(false); + const [showNewForm, setShowNewForm] = useState(false); const [showMenu, setShowMenu] = useState(null); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); const menuRef = useRef(null); - const [employees, setEmployees] = useState([ - { id: '1', name: 'John Doe', jobTitle: 'Cashier', department: 'Retail', status: 'Active', manager: 'Jane Manager', email: 'john.doe@company.com', phone: '(555) 123-4567', address: '123 Main St, City, ST 12345', hireDate: 'January 15, 2022' }, - { id: '2', name: 'Jane Smith', jobTitle: 'Stock Associate', department: 'Warehouse', status: 'Active', manager: 'Bob Manager', email: 'jane.smith@company.com', phone: '(555) 234-5678', address: '456 Oak Ave, City, ST 12345', hireDate: 'March 20, 2021' }, - { id: '3', name: 'Bob Johnson', jobTitle: 'Manager', department: 'Retail', status: 'Active', manager: 'Alice Director', email: 'bob.johnson@company.com', phone: '(555) 345-6789', address: '789 Pine Rd, City, ST 12345', hireDate: 'June 10, 2020' }, - ]); + + const [newEmployeeForm, setNewEmployeeForm] = useState({ + name: '', + email: '', + role: 'employee' as UserRole, + jobTitle: '', + department: '', + phone: '', + address: '', + }); + + const getAuthHeaders = () => { + const token = localStorage.getItem('token'); + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + }; + + useEffect(() => { + loadEmployees(); + }, []); + + const loadEmployees = async () => { + try { + setLoading(true); + const response = await fetch(`${API_URL}/employees`, { + headers: getAuthHeaders(), + }); + + if (!response.ok) { + throw new Error('Failed to load employees'); + } + + const data = await response.json(); + setEmployees(data.map((emp: any) => ({ + ...emp, + manager: emp.manager || 'N/A', + }))); + } catch (error) { + console.error('Error loading employees:', error); + setError('Failed to load employees'); + } finally { + setLoading(false); + } + }; // Close menu when clicking outside useEffect(() => { @@ -44,29 +95,129 @@ export const Employees: React.FC = () => { }; }, [showMenu]); + const handleNewEmployee = () => { + setNewEmployeeForm({ + name: '', + email: '', + role: 'employee', + jobTitle: '', + department: '', + phone: '', + address: '', + }); + setShowNewForm(true); + setError(''); + }; + + const handleCreateEmployee = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await fetch(`${API_URL}/users`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(newEmployeeForm), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to create employee'); + } + + await loadEmployees(); + setShowNewForm(false); + setNewEmployeeForm({ + name: '', + email: '', + role: 'employee', + jobTitle: '', + department: '', + phone: '', + address: '', + }); + } catch (error: any) { + console.error('Error creating employee:', error); + setError(error.message || 'Failed to create employee'); + } finally { + setLoading(false); + } + }; + const handleEdit = (emp: Employee) => { setEditingEmployee({ ...emp }); setShowEditForm(true); setShowMenu(null); + setError(''); }; - const handleSave = () => { - if (editingEmployee) { - setEmployees(employees.map(emp => - emp.id === editingEmployee.id ? editingEmployee : emp - )); + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingEmployee) return; + + setError(''); + setLoading(true); + + try { + const response = await fetch(`${API_URL}/employees/${editingEmployee.id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(editingEmployee), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update employee'); + } + + await loadEmployees(); setShowEditForm(false); setEditingEmployee(null); + } catch (error: any) { + console.error('Error updating employee:', error); + setError(error.message || 'Failed to update employee'); + } finally { + setLoading(false); } }; - const handleDelete = (id: string) => { - if (window.confirm('Are you sure you want to delete this employee?')) { - setEmployees(employees.filter(emp => emp.id !== id)); + const handleDelete = async (id: string) => { + if (!window.confirm('Are you sure you want to delete this employee?')) { + return; + } + + setError(''); + setLoading(true); + + try { + const response = await fetch(`${API_URL}/employees/${id}`, { + method: 'DELETE', + headers: getAuthHeaders(), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to delete employee'); + } + + await loadEmployees(); setShowMenu(null); + } catch (error: any) { + console.error('Error deleting employee:', error); + setError(error.message || 'Failed to delete employee'); + } finally { + setLoading(false); } }; + const filteredEmployees = employees.filter(emp => + emp.name.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.email?.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.jobTitle.toLowerCase().includes(searchTerm.toLowerCase()) || + emp.department.toLowerCase().includes(searchTerm.toLowerCase()) + ); + if (selectedEmployee) { const emp = employees.find(e => e.id === selectedEmployee); return ( @@ -177,7 +328,11 @@ export const Employees: React.FC = () => {

Employees

-
@@ -190,6 +345,8 @@ export const Employees: React.FC = () => { setSearchTerm(e.target.value)} className="bg-transparent border-none outline-none flex-1" />
@@ -200,21 +357,38 @@ export const Employees: React.FC = () => { + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + {/* Employees Table */}
- - - - - - - - - - - - - {employees.map(emp => ( + {loading && !employees.length ? ( +
Loading employees...
+ ) : ( +
NameJob TitleDepartmentStatusManagerActions
+ + + + + + + + + + + + {filteredEmployees.length === 0 ? ( + + + + ) : ( + filteredEmployees.map(emp => ( - ))} - -
NameJob TitleDepartmentStatusManagerActions
+ No employees found +
{emp.name}
@@ -282,9 +456,11 @@ export const Employees: React.FC = () => {
+ )) + )} + + + )}
{/* Edit Form Modal */} @@ -304,7 +480,13 @@ export const Employees: React.FC = () => { -
{ e.preventDefault(); handleSave(); }} className="space-y-4"> + {error && ( +
+ {error} +
+ )} + +
@@ -409,10 +591,135 @@ export const Employees: React.FC = () => { +
+ +
+ + )} + + {/* New Employee Form Modal */} + {showNewForm && ( +
+
+
+

New Employee

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+
+
+ + setNewEmployeeForm({ ...newEmployeeForm, name: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + setNewEmployeeForm({ ...newEmployeeForm, email: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + +
+
+ + setNewEmployeeForm({ ...newEmployeeForm, jobTitle: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + setNewEmployeeForm({ ...newEmployeeForm, department: e.target.value })} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + setNewEmployeeForm({ ...newEmployeeForm, phone: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ + setNewEmployeeForm({ ...newEmployeeForm, address: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> +
+
+ +
+ +
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index e656a6b..0c6f47c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -16,15 +16,28 @@ export const Login: React.FC = () => { setError(''); setLoading(true); - const success = await login(email, password); - - if (success) { - navigate('/'); - } else { - setError('Invalid email or password'); + try { + const success = await login(email, password); + + if (success) { + // Check if password change is required + const storedUser = localStorage.getItem('user'); + if (storedUser) { + const userData = JSON.parse(storedUser); + if (userData.mustChangePassword) { + navigate('/change-password'); + return; + } + } + navigate('/'); + } else { + setError('Invalid email or password. Make sure the backend server is running on http://localhost:3001'); + } + } catch (err) { + setError('Cannot connect to server. Please ensure the backend is running on http://localhost:3001'); + } finally { + setLoading(false); } - - setLoading(false); }; return ( diff --git a/src/pages/MyProfile.tsx b/src/pages/MyProfile.tsx new file mode 100644 index 0000000..740cf6d --- /dev/null +++ b/src/pages/MyProfile.tsx @@ -0,0 +1,322 @@ +import React, { useState, useEffect } from 'react'; +import { Layout } from '../components/Layout/Layout'; +import { useAuth } from '../context/AuthContext'; +import { User, Mail, Phone, MapPin, Briefcase, Calendar, Edit, Save, X } from 'lucide-react'; + +const API_URL = 'http://localhost:3001/api'; + +interface ProfileData { + id: string; + name: string; + email: string; + role: string; + job_title?: string; + department?: string; + phone?: string; + address?: string; + hire_date?: string; + manager?: string; + status?: string; +} + +export const MyProfile: React.FC = () => { + const { user } = useAuth(); + const [profileData, setProfileData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [editing, setEditing] = useState(false); + const [formData, setFormData] = useState({ + name: '', + email: '', + phone: '', + address: '', + }); + + useEffect(() => { + loadProfile(); + }, [user]); + + const loadProfile = async () => { + if (!user) return; + + try { + setLoading(true); + const token = localStorage.getItem('token'); + const response = await fetch(`${API_URL}/users/${user.id}`, { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error('Failed to load profile'); + } + + const data = await response.json(); + setProfileData(data); + setFormData({ + name: data.name || '', + email: data.email || '', + phone: data.phone || '', + address: data.address || '', + }); + } catch (error) { + console.error('Error loading profile:', error); + setError('Failed to load profile data'); + } finally { + setLoading(false); + } + }; + + const handleEdit = () => { + setEditing(true); + setError(''); + }; + + const handleCancel = () => { + setEditing(false); + if (profileData) { + setFormData({ + name: profileData.name || '', + email: profileData.email || '', + phone: profileData.phone || '', + address: profileData.address || '', + }); + } + }; + + const handleSave = async () => { + if (!user || !profileData) return; + + try { + setLoading(true); + setError(''); + const token = localStorage.getItem('token'); + const response = await fetch(`${API_URL}/users/${user.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + name: formData.name, + email: formData.email, + phone: formData.phone, + address: formData.address, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to update profile'); + } + + await loadProfile(); + setEditing(false); + } catch (error: any) { + console.error('Error updating profile:', error); + setError(error.message || 'Failed to update profile'); + } finally { + setLoading(false); + } + }; + + if (loading && !profileData) { + return ( + +
+
Loading profile...
+
+
+ ); + } + + if (!profileData) { + return ( + +
+
{error || 'Failed to load profile'}
+
+
+ ); + } + + return ( + +
+
+

My Profile

+ {!editing ? ( + + ) : ( +
+ + +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Profile Card */} +
+
+
+
+ + {profileData.name.split(' ').map(n => n[0]).join('')} + +
+

{profileData.name}

+

{profileData.job_title || 'Employee'}

+
+ {profileData.status || 'Active'} +
+
+
+
+ + {/* Details Card */} +
+
+

Personal Information

+ +
+
+
+ + {editing ? ( + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> + ) : ( +

{profileData.name}

+ )} +
+ +
+ + {editing ? ( + setFormData({ ...formData, email: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> + ) : ( +

{profileData.email}

+ )} +
+ +
+ + {editing ? ( + setFormData({ ...formData, phone: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + /> + ) : ( +

{profileData.phone || 'Not provided'}

+ )} +
+ +
+ +

{profileData.job_title || 'Not assigned'}

+
+ +
+ +

{profileData.department || 'Not assigned'}

+
+ +
+ +

+ {profileData.hire_date + ? new Date(profileData.hire_date).toLocaleDateString() + : 'Not set'} +

+
+ +
+ +

{profileData.manager || 'Not assigned'}

+
+
+ +
+ + {editing ? ( +