updated commit

This commit is contained in:
Fraggle 2025-12-07 19:37:52 -04:00
parent 5958758b3f
commit 6dc86dac45
No known key found for this signature in database
24 changed files with 3873 additions and 432 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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;

306
server/src/routes/shifts.js Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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' });

View File

@ -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) => {

View File

@ -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 (
<Routes>
<Route path="/timeclock" element={<TimeClock />} />
<Route path="/login" element={<LoginRoute />} />
<Route
path="/change-password"
element={
isAuthenticated ? <ChangePassword /> : <Navigate to="/login" replace />
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<MyProfile />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
<Route path="/timeclock" element={<TimeClock />} />
<Route
path="/"
element={
@ -63,6 +90,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/schedule-management"
element={
<ProtectedRoute>
<ScheduleManagement />
</ProtectedRoute>
}
/>
<Route
path="/documents"
element={

View File

@ -42,6 +42,7 @@ const navItems: NavItem[] = [
// Manager items
{ path: '/team-timecards', label: 'Team Timecards', icon: <FileText className="w-5 h-5" />, roles: ['manager'] },
{ path: '/team-schedules', label: 'Team Schedules', icon: <Calendar className="w-5 h-5" />, roles: ['manager'] },
{ path: '/schedule-management', label: 'Schedule Management', icon: <Calendar className="w-5 h-5" />, roles: ['manager', 'hr', 'admin'] },
{ path: '/approvals', label: 'Approvals', icon: <FileCheck className="w-5 h-5" />, roles: ['manager'] },
{ path: '/incident-report', label: 'Incident Report', icon: <AlertTriangle className="w-5 h-5" />, roles: ['manager'] },
// HR items

View File

@ -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<Notification[]>(() => {
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 (
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 sticky top-0 z-50">
<div className="flex items-center gap-6">
@ -35,15 +123,94 @@ export const TopBar: React.FC = () => {
</div>
<div className="flex items-center gap-4">
<button className="relative p-2 hover:bg-gray-100 rounded-lg transition-colors">
<Bell className="w-5 h-5 text-gray-600" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="notification-button relative p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<Bell className="w-5 h-5 text-gray-600" />
{notifications.length > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
)}
</button>
{showNotifications && (
<div className="notification-menu absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-96 overflow-y-auto">
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
{notifications.length > 0 && (
<button
onClick={handleClearAllNotifications}
className="text-xs text-gray-500 hover:text-gray-700 font-medium"
>
Clear all
</button>
)}
</div>
<div className="divide-y divide-gray-200">
{notifications.length === 0 ? (
<div className="p-8 text-center">
<Bell className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No notifications</p>
</div>
) : (
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 (
<div
key={notification.id}
className="p-4 hover:bg-gray-50 cursor-pointer relative group"
onClick={() => handleNotificationClick(notification.path)}
>
<div className="flex items-start gap-3">
<div className={`w-2 h-2 ${getColor()} rounded-full mt-2`}></div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{notification.title}</p>
<p className="text-xs text-gray-500 mt-1">{notification.time}</p>
</div>
<button
onClick={(e) => handleDismissNotification(notification.id, e)}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 transition-opacity ml-2"
title="Dismiss"
>
<span className="text-lg">×</span>
</button>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="p-3 border-t border-gray-200 text-center">
<button
onClick={handleClearAllNotifications}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Clear all notifications
</button>
</div>
)}
</div>
)}
</div>
<div className="relative">
<button
onClick={() => setShowProfileMenu(!showProfileMenu)}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
className="profile-button flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<div className="w-8 h-8 bg-primary-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-medium">
@ -55,21 +222,27 @@ export const TopBar: React.FC = () => {
</button>
{showProfileMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2">
<a
href="#"
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700"
<div className="profile-menu absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<button
onClick={() => {
navigate('/profile');
setShowProfileMenu(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700 text-left"
>
<User className="w-4 h-4" />
My Profile
</a>
<a
href="#"
className="flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700"
</button>
<button
onClick={() => {
navigate('/settings');
setShowProfileMenu(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 hover:bg-gray-50 text-sm text-gray-700 text-left"
>
<Settings className="w-4 h-4" />
Settings
</a>
</button>
<hr className="my-2" />
<button
onClick={handleLogout}

View File

@ -3,12 +3,17 @@ import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuth();
const { isAuthenticated, mustChangePassword } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// Redirect to password change if required
if (mustChangePassword) {
return <Navigate to="/change-password" replace />;
}
return <>{children}</>;
};

View File

@ -6,6 +6,7 @@ interface AuthContextType {
login: (email: string, password: string) => Promise<boolean>;
logout: () => void;
isAuthenticated: boolean;
mustChangePassword: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -18,89 +19,118 @@ export const useAuth = () => {
return context;
};
// Mock users database - in production, this would be in a real database
const MOCK_USERS = [
{
id: '1',
email: 'admin@company.com',
password: 'admin123',
name: 'Admin User',
role: 'admin' as UserRole,
},
{
id: '2',
email: 'hr@company.com',
password: 'hr123',
name: 'HR Manager',
role: 'hr' as UserRole,
},
{
id: '3',
email: 'payroll@company.com',
password: 'payroll123',
name: 'Payroll Admin',
role: 'payroll' as UserRole,
},
{
id: '4',
email: 'manager@company.com',
password: 'manager123',
name: 'Team Manager',
role: 'manager' as UserRole,
},
{
id: '5',
email: 'employee@company.com',
password: 'employee123',
name: 'John Doe',
role: 'employee' as UserRole,
},
];
const API_URL = 'http://localhost:3001/api';
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Check for stored session
// Check for stored session and token
const storedUser = localStorage.getItem('user');
if (storedUser) {
const storedToken = localStorage.getItem('token');
if (storedUser && storedToken) {
try {
setUser(JSON.parse(storedUser));
const userData = JSON.parse(storedUser);
setUser(userData);
// Verify token is still valid by fetching current user
fetch(`${API_URL}/auth/me`, {
headers: {
'Authorization': `Bearer ${storedToken}`
}
})
.then(res => {
if (res.ok) {
return res.json();
} else {
// Token invalid, clear session
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
throw new Error('Invalid token');
}
})
.then(data => {
// Update user data including mustChangePassword flag
if (data.user) {
const updatedUser = {
...userData,
mustChangePassword: data.user.mustChangePassword || false
};
setUser(updatedUser);
localStorage.setItem('user', JSON.stringify(updatedUser));
}
})
.catch(() => {
// Network error, clear session
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
});
} catch (e) {
localStorage.removeItem('user');
localStorage.removeItem('token');
}
}
}, []);
const login = async (email: string, password: string): Promise<boolean> => {
// Simulate API call delay
await new Promise(resolve => setTimeout(resolve, 500));
const foundUser = MOCK_USERS.find(
u => u.email === email && u.password === password
);
try {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
});
if (foundUser) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Login failed' }));
console.error('Login failed:', errorData.error || 'Unknown error');
return false;
}
const data = await response.json();
if (!data.user || !data.token) {
console.error('Invalid response from server');
return false;
}
const userData: User = {
id: foundUser.id,
name: foundUser.name,
email: foundUser.email,
role: foundUser.role,
id: data.user.id,
name: data.user.name,
email: data.user.email,
role: data.user.role,
mustChangePassword: data.user.mustChangePassword || false,
};
setUser(userData);
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('token', data.token);
return true;
} catch (error) {
console.error('Login error:', error);
// Network error - backend might not be running
if (error instanceof TypeError && error.message.includes('fetch')) {
console.error('Cannot connect to backend server. Make sure it is running on http://localhost:3001');
}
return false;
}
return false;
};
const logout = () => {
setUser(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
};
const mustChangePassword = user?.mustChangePassword || false;
return (
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user }}>
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user, mustChangePassword }}>
{children}
</AuthContext.Provider>
);

View File

@ -1,109 +0,0 @@
import { User, Timecard, Shift, DisciplinaryAction, Receipt, PayrollRun } from '../types';
// Mock database - In production, this would be replaced with API calls to a real backend
class MockDatabase {
private users: User[] = [
{
id: '1',
name: 'John Doe',
email: 'john.doe@company.com',
role: 'employee',
},
{
id: '2',
name: 'Jane Smith',
email: 'jane.smith@company.com',
role: 'employee',
},
{
id: '3',
name: 'Bob Johnson',
email: 'bob.johnson@company.com',
role: 'employee',
},
{
id: '4',
name: 'Alice Brown',
email: 'alice.brown@company.com',
role: 'manager',
},
{
id: '5',
name: 'Charlie Wilson',
email: 'charlie.wilson@company.com',
role: 'employee',
},
];
private timecards: Timecard[] = [];
private shifts: Shift[] = [];
private disciplinaryActions: DisciplinaryAction[] = [];
private receipts: Receipt[] = [];
private payrollRuns: PayrollRun[] = [];
// User CRUD operations
getUsers(): User[] {
return [...this.users];
}
getUserById(id: string): User | undefined {
return this.users.find(u => u.id === id);
}
createUser(userData: Omit<User, 'id'>): User {
const newUser: User = {
...userData,
id: String(Date.now()),
};
this.users.push(newUser);
this.saveToLocalStorage();
return newUser;
}
updateUser(id: string, updates: Partial<User>): User | null {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) return null;
this.users[index] = { ...this.users[index], ...updates };
this.saveToLocalStorage();
return this.users[index];
}
deleteUser(id: string): boolean {
const index = this.users.findIndex(u => u.id === id);
if (index === -1) return false;
this.users.splice(index, 1);
this.saveToLocalStorage();
return true;
}
// Local storage persistence
private saveToLocalStorage() {
try {
localStorage.setItem('mockUsers', JSON.stringify(this.users));
} catch (e) {
console.error('Failed to save to localStorage:', e);
}
}
loadFromLocalStorage() {
try {
const stored = localStorage.getItem('mockUsers');
if (stored) {
this.users = JSON.parse(stored);
}
} catch (e) {
console.error('Failed to load from localStorage:', e);
}
}
// Initialize
constructor() {
this.loadFromLocalStorage();
}
}
export const mockDatabase = new MockDatabase();

View File

@ -14,19 +14,24 @@ if (!rootElement) {
}
try {
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<UserProvider>
<App />
</UserProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>,
)
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<ErrorBoundary>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider>
<UserProvider>
<App />
</UserProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>,
)
} catch (error) {
console.error('Failed to render app:', error)
rootElement.innerHTML = `

View File

@ -0,0 +1,212 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
const API_URL = 'http://localhost:3001/api';
export const ChangePassword: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess(false);
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError('All fields are required');
return;
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters long');
return;
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match');
return;
}
if (currentPassword === newPassword) {
setError('New password must be different from current password');
return;
}
setLoading(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_URL}/auth/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Failed to change password');
setLoading(false);
return;
}
setSuccess(true);
// Update user in localStorage to clear mustChangePassword flag
const storedUser = localStorage.getItem('user');
if (storedUser) {
const userData = JSON.parse(storedUser);
userData.mustChangePassword = false;
localStorage.setItem('user', JSON.stringify(userData));
}
// Redirect after a short delay
setTimeout(() => {
// Force a page reload to update the auth context
window.location.href = '/';
}, 2000);
} catch (error) {
console.error('Change password error:', error);
setError('Network error. Please try again.');
setLoading(false);
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-primary-100 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8">
<div className="text-center mb-8">
<div className="w-16 h-16 bg-yellow-500 rounded-xl flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Change Password</h1>
<p className="text-gray-600">
{user?.mustChangePassword
? 'You must change your password before continuing.'
: 'Update your password'}
</p>
</div>
{success ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Password Changed!</h2>
<p className="text-gray-600 mb-6">Your password has been successfully updated.</p>
<p className="text-sm text-gray-500">Redirecting to dashboard...</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-sm text-red-800">{error}</span>
</div>
)}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
placeholder="Enter your current password"
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
placeholder="Enter your new password (min. 6 characters)"
/>
<p className="text-xs text-gray-500 mt-1">Must be at least 6 characters long</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition"
placeholder="Confirm your new password"
/>
</div>
<div className="flex gap-3">
{!user?.mustChangePassword && (
<button
type="button"
onClick={handleLogout}
className="flex-1 px-4 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Cancel
</button>
)}
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
Changing...
</>
) : (
<>
<Lock className="w-5 h-5" />
Change Password
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};

View File

@ -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 = () => (
<div className="space-y-6">
@ -15,6 +84,11 @@ export const Dashboard: React.FC = () => {
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Current Status</h2>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="flex items-center gap-4">
<div className={`px-4 py-2 rounded-full text-sm font-medium ${
clockStatus === 'clocked_in' ? 'bg-green-100 text-green-800' :
@ -25,13 +99,31 @@ export const Dashboard: React.FC = () => {
clockStatus === 'on_break' ? 'ON BREAK' :
'CLOCKED OUT'}
</div>
<button className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors">
{clockStatus === 'clocked_out' ? 'Clock In' :
clockStatus === 'clocked_in' ? 'Clock Out' :
'End Break'}
<button
onClick={() => {
if (clockStatus === 'clocked_out') {
handleClockAction('clock_in');
} else if (clockStatus === 'clocked_in') {
handleClockAction('clock_out');
} else {
handleClockAction('break_end');
}
}}
disabled={loading}
className="px-6 py-3 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Processing...' : (
clockStatus === 'clocked_out' ? 'Clock In' :
clockStatus === 'clocked_in' ? 'Clock Out' :
'End Break'
)}
</button>
{clockStatus === 'clocked_in' && (
<button className="px-6 py-3 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 transition-colors">
<button
onClick={() => handleClockAction('break_start')}
disabled={loading}
className="px-6 py-3 bg-yellow-500 text-white rounded-lg font-medium hover:bg-yellow-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Start Break
</button>
)}
@ -193,10 +285,13 @@ export const Dashboard: React.FC = () => {
<h3 className="font-semibold text-gray-900 mb-2">Create Incident Report</h3>
<p className="text-sm text-gray-600">Report a workplace incident</p>
</button>
<button className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left">
<Link
to="/schedule-management"
className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left"
>
<h3 className="font-semibold text-gray-900 mb-2">Edit Team Schedule</h3>
<p className="text-sm text-gray-600">Manage upcoming shifts</p>
</button>
</Link>
</div>
</div>
);
@ -253,10 +348,13 @@ export const Dashboard: React.FC = () => {
<h3 className="font-semibold text-gray-900 mb-2">Start Disciplinary Action</h3>
<p className="text-sm text-gray-600">Initiate a disciplinary process</p>
</button>
<button className="p-6 bg-white rounded-lg shadow-sm border-2 border-yellow-500 hover:bg-yellow-50 transition-colors">
<h3 className="font-semibold text-gray-900 mb-2">Schedule Review</h3>
<p className="text-sm text-gray-600">Plan performance reviews</p>
</button>
<Link
to="/schedule-management"
className="p-6 bg-white rounded-lg shadow-sm border-2 border-blue-500 hover:bg-blue-50 transition-colors"
>
<h3 className="font-semibold text-gray-900 mb-2">Manage Schedules</h3>
<p className="text-sm text-gray-600">Create and manage employee schedules</p>
</Link>
</div>
{/* Alerts */}
@ -364,7 +462,7 @@ export const Dashboard: React.FC = () => {
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Link to="/system-settings" className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left block">
<Settings className="w-8 h-8 text-primary-600 mb-3" />
<h3 className="font-semibold text-gray-900 mb-2">System Settings</h3>
@ -375,6 +473,11 @@ export const Dashboard: React.FC = () => {
<h3 className="font-semibold text-gray-900 mb-2">User Provisioning</h3>
<p className="text-sm text-gray-600">Manage user accounts</p>
</Link>
<Link to="/schedule-management" className="p-6 bg-white rounded-lg shadow-sm border border-gray-200 hover:border-primary-500 transition-colors text-left block">
<Calendar className="w-8 h-8 text-primary-600 mb-3" />
<h3 className="font-semibold text-gray-900 mb-2">Schedule Management</h3>
<p className="text-sm text-gray-600">Create and manage employee schedules</p>
</Link>
</div>
</div>
);

View File

@ -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<string | null>(null);
const [editingEmployee, setEditingEmployee] = useState<Employee | null>(null);
const [showEditForm, setShowEditForm] = useState(false);
const [showNewForm, setShowNewForm] = useState(false);
const [showMenu, setShowMenu] = useState<string | null>(null);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const menuRef = useRef<HTMLDivElement>(null);
const [employees, setEmployees] = useState<Employee[]>([
{ 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 = () => {
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900">Employees</h1>
<button className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700">
<button
onClick={handleNewEmployee}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Employee
</button>
</div>
@ -190,6 +345,8 @@ export const Employees: React.FC = () => {
<input
type="text"
placeholder="Search employees..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-transparent border-none outline-none flex-1"
/>
</div>
@ -200,21 +357,38 @@ export const Employees: React.FC = () => {
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
{/* Employees Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Title</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Department</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Manager</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{employees.map(emp => (
{loading && !employees.length ? (
<div className="p-8 text-center text-gray-500">Loading employees...</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Job Title</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Department</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Manager</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredEmployees.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-gray-500">
No employees found
</td>
</tr>
) : (
filteredEmployees.map(emp => (
<tr key={emp.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{emp.name}</div>
@ -282,9 +456,11 @@ export const Employees: React.FC = () => {
</div>
</td>
</tr>
))}
</tbody>
</table>
))
)}
</tbody>
</table>
)}
</div>
{/* Edit Form Modal */}
@ -304,7 +480,13 @@ export const Employees: React.FC = () => {
</button>
</div>
<form onSubmit={(e) => { e.preventDefault(); handleSave(); }} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
@ -409,10 +591,135 @@ export const Employees: React.FC = () => {
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2"
disabled={loading}
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Changes
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* New Employee Form Modal */}
{showNewForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900">New Employee</h2>
<button
onClick={() => {
setShowNewForm(false);
setError('');
}}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleCreateEmployee} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full Name *</label>
<input
type="text"
value={newEmployeeForm.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email *</label>
<input
type="email"
value={newEmployeeForm.email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Role *</label>
<select
value={newEmployeeForm.role}
onChange={(e) => setNewEmployeeForm({ ...newEmployeeForm, role: e.target.value as UserRole })}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="employee">Employee</option>
<option value="manager">Manager</option>
<option value="hr">HR</option>
<option value="payroll">Payroll</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Job Title *</label>
<input
type="text"
value={newEmployeeForm.jobTitle}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Department *</label>
<input
type="text"
value={newEmployeeForm.department}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input
type="tel"
value={newEmployeeForm.phone}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Address</label>
<input
type="text"
value={newEmployeeForm.address}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={() => {
setShowNewForm(false);
setError('');
}}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4" />
{loading ? 'Creating...' : 'Create Employee'}
</button>
</div>
</form>

View File

@ -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 (

322
src/pages/MyProfile.tsx Normal file
View File

@ -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<ProfileData | null>(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 (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Loading profile...</div>
</div>
</Layout>
);
}
if (!profileData) {
return (
<Layout>
<div className="flex items-center justify-center h-64">
<div className="text-red-500">{error || 'Failed to load profile'}</div>
</div>
</Layout>
);
}
return (
<Layout>
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900">My Profile</h1>
{!editing ? (
<button
onClick={handleEdit}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
Edit Profile
</button>
) : (
<div className="flex gap-2">
<button
onClick={handleCancel}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 flex items-center gap-2"
>
<X className="w-4 h-4" />
Cancel
</button>
<button
onClick={handleSave}
disabled={loading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{loading ? 'Saving...' : 'Save Changes'}
</button>
</div>
)}
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Profile Card */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="text-center">
<div className="w-24 h-24 bg-primary-500 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white text-3xl font-bold">
{profileData.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{profileData.name}</h2>
<p className="text-gray-600 mb-4">{profileData.job_title || 'Employee'}</p>
<div className="inline-block px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
{profileData.status || 'Active'}
</div>
</div>
</div>
</div>
{/* Details Card */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-6">Personal Information</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="w-4 h-4 inline mr-2" />
Full Name
</label>
{editing ? (
<input
type="text"
value={formData.name}
onChange={(e) => 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"
/>
) : (
<p className="text-gray-900 font-medium">{profileData.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="w-4 h-4 inline mr-2" />
Email Address
</label>
{editing ? (
<input
type="email"
value={formData.email}
onChange={(e) => 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"
/>
) : (
<p className="text-gray-900 font-medium">{profileData.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="w-4 h-4 inline mr-2" />
Phone
</label>
{editing ? (
<input
type="tel"
value={formData.phone}
onChange={(e) => 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"
/>
) : (
<p className="text-gray-900 font-medium">{profileData.phone || 'Not provided'}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Briefcase className="w-4 h-4 inline mr-2" />
Job Title
</label>
<p className="text-gray-900 font-medium">{profileData.job_title || 'Not assigned'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Briefcase className="w-4 h-4 inline mr-2" />
Department
</label>
<p className="text-gray-900 font-medium">{profileData.department || 'Not assigned'}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Calendar className="w-4 h-4 inline mr-2" />
Hire Date
</label>
<p className="text-gray-900 font-medium">
{profileData.hire_date
? new Date(profileData.hire_date).toLocaleDateString()
: 'Not set'}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="w-4 h-4 inline mr-2" />
Manager
</label>
<p className="text-gray-900 font-medium">{profileData.manager || 'Not assigned'}</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<MapPin className="w-4 h-4 inline mr-2" />
Address
</label>
{editing ? (
<textarea
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
) : (
<p className="text-gray-900 font-medium">{profileData.address || 'Not provided'}</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
};

View File

@ -0,0 +1,898 @@
import React, { useState, useEffect } from 'react';
import { Layout } from '../components/Layout/Layout';
import { Calendar, ChevronLeft, ChevronRight, Plus, Edit2, Trash2, Send, AlertTriangle, X, Copy, Clipboard } from 'lucide-react';
import { format, startOfWeek, endOfWeek, addWeeks, subWeeks, eachDayOfInterval, startOfMonth, endOfMonth, addMonths, subMonths, isSameDay, parseISO } from 'date-fns';
import { useAuth } from '../context/AuthContext';
const API_URL = 'http://localhost:3001/api';
interface Shift {
id: string;
employee_id: string;
employee_name: string;
date: string;
start_time: string;
end_time: string;
location?: string;
notes?: string;
}
interface Employee {
id: string;
name: string;
}
export const ScheduleManagement: React.FC = () => {
const { user } = useAuth();
const [view, setView] = useState<'week' | 'month'>('week');
const [currentDate, setCurrentDate] = useState(new Date());
const [shifts, setShifts] = useState<Shift[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [loading, setLoading] = useState(true);
const [showShiftModal, setShowShiftModal] = useState(false);
const [editingShift, setEditingShift] = useState<Shift | null>(null);
const [selectedEmployee, setSelectedEmployee] = useState<string>('');
const [selectedDate, setSelectedDate] = useState<string>('');
const [startTime, setStartTime] = useState<string>('');
const [endTime, setEndTime] = useState<string>('');
const [location, setLocation] = useState<string>('');
const [notes, setNotes] = useState<string>('');
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [shiftToDelete, setShiftToDelete] = useState<Shift | null>(null);
const [showHoursWarning, setShowHoursWarning] = useState(false);
const [showHoursConfirm, setShowHoursConfirm] = useState(false);
const [hoursWarning, setHoursWarning] = useState<{ employeeName: string; totalHours: number } | null>(null);
const [copiedShift, setCopiedShift] = useState<{ startTime: string; endTime: string; location?: string; notes?: string } | null>(null);
useEffect(() => {
fetchEmployees();
fetchShifts();
}, [currentDate, view]);
const fetchEmployees = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_URL}/employees`, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setEmployees(data.filter((emp: any) => emp.status === 'Active'));
}
} catch (error) {
console.error('Error fetching employees:', error);
}
};
const fetchShifts = async () => {
setLoading(true);
try {
const token = localStorage.getItem('token');
let startDate: string;
let endDate: string;
if (view === 'week') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 });
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 0 });
startDate = format(weekStart, 'yyyy-MM-dd');
endDate = format(weekEnd, 'yyyy-MM-dd');
} else {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
startDate = format(monthStart, 'yyyy-MM-dd');
endDate = format(monthEnd, 'yyyy-MM-dd');
}
const response = await fetch(
`${API_URL}/shifts?startDate=${startDate}&endDate=${endDate}`,
{
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (response.ok) {
const data = await response.json();
setShifts(data);
}
} catch (error) {
console.error('Error fetching shifts:', error);
} finally {
setLoading(false);
}
};
const handlePrevious = () => {
if (view === 'week') {
setCurrentDate(subWeeks(currentDate, 1));
} else {
setCurrentDate(subMonths(currentDate, 1));
}
};
const handleNext = () => {
if (view === 'week') {
setCurrentDate(addWeeks(currentDate, 1));
} else {
setCurrentDate(addMonths(currentDate, 1));
}
};
const handleToday = () => {
setCurrentDate(new Date());
};
const openShiftModal = (employeeId?: string, date?: string, shift?: Shift) => {
if (shift) {
setEditingShift(shift);
setSelectedEmployee(shift.employee_id);
setSelectedDate(shift.date);
setStartTime(shift.start_time);
setEndTime(shift.end_time);
setLocation(shift.location || '');
setNotes(shift.notes || '');
} else {
setEditingShift(null);
setSelectedEmployee(employeeId || '');
setSelectedDate(date || format(new Date(), 'yyyy-MM-dd'));
// If we have a copied shift, paste it automatically
if (copiedShift && !shift) {
setStartTime(copiedShift.startTime);
setEndTime(copiedShift.endTime);
setLocation(copiedShift.location || '');
setNotes(copiedShift.notes || '');
} else {
setStartTime('');
setEndTime('');
setLocation('');
setNotes('');
}
}
setShowShiftModal(true);
};
const closeShiftModal = () => {
setShowShiftModal(false);
setEditingShift(null);
setSelectedEmployee('');
setSelectedDate('');
setStartTime('');
setEndTime('');
setLocation('');
setNotes('');
};
const calculateShiftHours = (startTime: string, endTime: string): number => {
const [startHours, startMinutes] = startTime.split(':').map(Number);
const [endHours, endMinutes] = endTime.split(':').map(Number);
const startTotal = startHours + startMinutes / 60;
const endTotal = endHours + endMinutes / 60;
return endTotal - startTotal;
};
const getTotalHoursForEmployee = (employeeId: string, dateRange: { start: Date; end: Date }): number => {
const startStr = format(dateRange.start, 'yyyy-MM-dd');
const endStr = format(dateRange.end, 'yyyy-MM-dd');
return shifts
.filter(shift => {
// Exclude the shift being edited from the calculation
if (editingShift && shift.id === editingShift.id) {
return false;
}
return shift.employee_id === employeeId &&
shift.date >= startStr &&
shift.date <= endStr;
})
.reduce((total, shift) => {
return total + calculateShiftHours(shift.start_time, shift.end_time);
}, 0);
};
const handleSaveShift = async () => {
if (!selectedEmployee || !selectedDate || !startTime || !endTime) {
alert('Please fill in all required fields');
return;
}
// Calculate hours for the new/updated shift
const shiftHours = calculateShiftHours(startTime, endTime);
// Determine date range based on view
let dateRange: { start: Date; end: Date };
if (view === 'week') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 });
dateRange = {
start: weekStart,
end: endOfWeek(weekStart, { weekStartsOn: 0 })
};
} else {
dateRange = {
start: startOfMonth(currentDate),
end: endOfMonth(currentDate)
};
}
// Calculate total hours including this shift
const existingHours = getTotalHoursForEmployee(selectedEmployee, dateRange);
const totalHours = existingHours + shiftHours;
const employee = employees.find(emp => emp.id === selectedEmployee);
const employeeName = employee?.name || 'Employee';
// Check for hours limits
if (totalHours > 48) {
setHoursWarning({ employeeName, totalHours });
setShowHoursConfirm(true);
return;
} else if (totalHours > 40) {
setHoursWarning({ employeeName, totalHours });
setShowHoursWarning(true);
// Continue with save after user acknowledges
}
// If we get here and hours warning is shown, user has acknowledged
// Proceed with save
await performSaveShift();
};
const performSaveShift = async () => {
if (!selectedEmployee || !selectedDate || !startTime || !endTime) {
return;
}
try {
const token = localStorage.getItem('token');
const url = editingShift
? `${API_URL}/shifts/${editingShift.id}`
: `${API_URL}/shifts`;
const method = editingShift ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
employeeId: selectedEmployee,
date: selectedDate,
startTime,
endTime,
location: location || undefined,
notes: notes || undefined,
}),
});
if (response.ok) {
closeShiftModal();
setShowHoursWarning(false);
setShowHoursConfirm(false);
setHoursWarning(null);
fetchShifts();
} else {
const data = await response.json();
alert(data.error || 'Failed to save shift');
}
} catch (error) {
console.error('Error saving shift:', error);
alert('Network error. Please try again.');
}
};
const handleHoursWarningConfirm = () => {
setShowHoursWarning(false);
performSaveShift();
};
const handleHoursWarningCancel = () => {
setShowHoursWarning(false);
setHoursWarning(null);
};
const handleHoursConfirmAccept = () => {
setShowHoursConfirm(false);
setHoursWarning(null);
performSaveShift();
};
const handleHoursConfirmCancel = () => {
setShowHoursConfirm(false);
setHoursWarning(null);
};
const handleDeleteClick = (shift: Shift) => {
setShiftToDelete(shift);
setShowDeleteModal(true);
};
const handleDeleteConfirm = async () => {
if (!shiftToDelete) return;
try {
const token = localStorage.getItem('token');
const response = await fetch(`${API_URL}/shifts/${shiftToDelete.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
setShowDeleteModal(false);
setShiftToDelete(null);
fetchShifts();
} else {
const data = await response.json();
alert(data.error || 'Failed to delete shift');
}
} catch (error) {
console.error('Error deleting shift:', error);
alert('Network error. Please try again.');
}
};
const handleDeleteCancel = () => {
setShowDeleteModal(false);
setShiftToDelete(null);
};
const handleCopyShift = (shift: Shift, e: React.MouseEvent) => {
e.stopPropagation();
setCopiedShift({
startTime: shift.start_time,
endTime: shift.end_time,
location: shift.location || undefined,
notes: shift.notes || undefined,
});
};
const handlePasteShift = async (employeeId: string, date: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!copiedShift) {
return;
}
// Check if shift already exists for this employee and date
const existingShift = getShiftForEmployeeAndDate(employeeId, parseISO(date));
if (existingShift) {
if (!window.confirm('A shift already exists for this date. Do you want to replace it?')) {
return;
}
}
// Set up the form with copied data
setSelectedEmployee(employeeId);
setSelectedDate(date);
setStartTime(copiedShift.startTime);
setEndTime(copiedShift.endTime);
setLocation(copiedShift.location || '');
setNotes(copiedShift.notes || '');
setEditingShift(existingShift || null);
// Calculate hours for validation
const shiftHours = calculateShiftHours(copiedShift.startTime, copiedShift.endTime);
// Determine date range based on view
let dateRange: { start: Date; end: Date };
if (view === 'week') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 });
dateRange = {
start: weekStart,
end: endOfWeek(weekStart, { weekStartsOn: 0 })
};
} else {
dateRange = {
start: startOfMonth(currentDate),
end: endOfMonth(currentDate)
};
}
// Calculate total hours including this shift
const existingHours = getTotalHoursForEmployee(employeeId, dateRange);
const totalHours = existingHours + shiftHours;
const employee = employees.find(emp => emp.id === employeeId);
const employeeName = employee?.name || 'Employee';
// Check for hours limits
if (totalHours > 48) {
setHoursWarning({ employeeName, totalHours });
setShowHoursConfirm(true);
return;
} else if (totalHours > 40) {
setHoursWarning({ employeeName, totalHours });
setShowHoursWarning(true);
return;
}
// If no warnings, save directly
await performSaveShift();
};
const getShiftForEmployeeAndDate = (employeeId: string, date: Date): Shift | null => {
const dateStr = format(date, 'yyyy-MM-dd');
return shifts.find(
(shift) => shift.employee_id === employeeId && shift.date === dateStr
) || null;
};
const formatShiftTime = (shift: Shift): string => {
// Convert 24-hour format to 12-hour format for display
const formatTime = (time: string) => {
const [hours, minutes] = time.split(':');
const hour = parseInt(hours);
const ampm = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour % 12 || 12;
return `${displayHour}:${minutes} ${ampm}`;
};
return `${formatTime(shift.start_time)}-${formatTime(shift.end_time)}`;
};
const getDaysInView = (): Date[] => {
if (view === 'week') {
const weekStart = startOfWeek(currentDate, { weekStartsOn: 0 });
return eachDayOfInterval({ start: weekStart, end: endOfWeek(weekStart, { weekStartsOn: 0 }) });
} else {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
return eachDayOfInterval({ start: monthStart, end: monthEnd });
}
};
const days = getDaysInView();
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-gray-900">Schedule Management</h1>
<div className="flex items-center gap-4">
<div className="flex gap-2">
<button
onClick={() => setView('week')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
view === 'week'
? 'bg-primary-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Week
</button>
<button
onClick={() => setView('month')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
view === 'month'
? 'bg-primary-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Month
</button>
</div>
<button
onClick={handlePrevious}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronLeft className="w-5 h-5" />
</button>
<button
onClick={handleToday}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Today
</button>
<button
onClick={handleNext}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
<div className="text-lg font-semibold text-gray-900 min-w-[200px] text-center">
{view === 'week'
? `Week of ${format(startOfWeek(currentDate, { weekStartsOn: 0 }), 'MMM d')}`
: format(currentDate, 'MMMM yyyy')}
</div>
</div>
</div>
{/* Calendar Table */}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
) : (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-4 py-3 text-left text-sm font-semibold text-gray-900 sticky left-0 bg-gray-50 z-10 border-r border-gray-200">
Employee
</th>
{days.map((day) => (
<th
key={day.toISOString()}
className="px-4 py-3 text-center text-sm font-semibold text-gray-900 min-w-[120px]"
>
<div>{format(day, 'EEE')}</div>
<div className="text-xs text-gray-500 font-normal">{format(day, 'MMM d')}</div>
</th>
))}
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<tr key={employee.id} className="border-b border-gray-200 hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900 sticky left-0 bg-white z-10 border-r border-gray-200">
{employee.name}
</td>
{days.map((day) => {
const shift = getShiftForEmployeeAndDate(employee.id, day);
const dateStr = format(day, 'yyyy-MM-dd');
const isPast = day < new Date() && !isSameDay(day, new Date());
return (
<td
key={`${employee.id}-${dateStr}`}
className="px-2 py-3 text-center text-xs border-r border-gray-100"
>
{shift ? (
<div className="relative group">
<div className="bg-primary-100 text-primary-800 px-2 py-1 rounded cursor-pointer hover:bg-primary-200 transition-colors">
{formatShiftTime(shift)}
</div>
<div className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 z-20">
<button
onClick={(e) => handleCopyShift(shift, e)}
className="p-1 bg-white rounded shadow hover:bg-gray-100"
title="Copy shift"
>
<Copy className="w-3 h-3 text-blue-600" />
</button>
<button
onClick={() => openShiftModal(undefined, undefined, shift)}
className="p-1 bg-white rounded shadow hover:bg-gray-100"
title="Edit"
>
<Edit2 className="w-3 h-3 text-primary-600" />
</button>
<button
onClick={() => handleDeleteClick(shift)}
className="p-1 bg-white rounded shadow hover:bg-gray-100"
title="Delete"
>
<Trash2 className="w-3 h-3 text-red-600" />
</button>
</div>
</div>
) : (
<div className="relative group">
<button
onClick={() => openShiftModal(employee.id, dateStr)}
className={`w-full h-8 text-gray-400 hover:bg-gray-100 rounded transition-colors ${
isPast ? 'opacity-50 cursor-not-allowed' : ''
}`}
disabled={isPast}
title={isPast ? 'Cannot add shifts to past dates' : 'Add shift'}
>
{isPast ? '' : '+'}
</button>
{copiedShift && !isPast && (
<button
onClick={(e) => handlePasteShift(employee.id, dateStr, e)}
className="absolute right-0 top-0 opacity-0 group-hover:opacity-100 transition-opacity p-1 bg-white rounded shadow hover:bg-gray-100 z-20"
title="Paste shift"
>
<Clipboard className="w-3 h-3 text-green-600" />
</button>
)}
</div>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Legend */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<p className="text-sm text-gray-600 mb-2">
<strong>Legend:</strong> Times are shift start-end; Click on a cell to add a shift, or click on an existing shift to edit/delete. Use the copy button to copy a shift, then paste it to other cells.
</p>
{copiedShift && (
<div className="flex items-center gap-2 mt-2 p-2 bg-blue-50 border border-blue-200 rounded">
<Clipboard className="w-4 h-4 text-blue-600" />
<span className="text-sm text-blue-800">
<strong>Shift copied:</strong> {copiedShift.startTime} - {copiedShift.endTime}
{copiedShift.location && ` (${copiedShift.location})`}
</span>
<button
onClick={() => setCopiedShift(null)}
className="ml-auto text-blue-600 hover:text-blue-800"
title="Clear copied shift"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Shift Modal */}
{showShiftModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
{editingShift ? 'Edit Shift' : 'Create Shift'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Employee *
</label>
<select
value={selectedEmployee}
onChange={(e) => setSelectedEmployee(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"
disabled={!!editingShift}
>
<option value="">Select employee</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date *
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Start Time * (HH:MM)
</label>
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
End Time * (HH:MM)
</label>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Location
</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., Main Store, Warehouse"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional notes..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={closeShiftModal}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleSaveShift}
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 transition-colors"
>
{editingShift ? 'Update' : 'Create'} Shift
</button>
</div>
</div>
</div>
)}
{/* Hours Warning Modal (40+ hours) */}
{showHoursWarning && hoursWarning && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-yellow-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900">Schedule Hours Warning</h2>
</div>
<div className="mb-6">
<p className="text-gray-700 mb-4">
<strong>{hoursWarning.employeeName}</strong> will have{' '}
<strong className="text-yellow-600">{hoursWarning.totalHours.toFixed(1)} hours</strong> scheduled
{view === 'week' ? ' this week' : ' this month'}.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> This exceeds the standard 40-hour work week. Please ensure this is intentional
and complies with labor regulations.
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleHoursWarningCancel}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleHoursWarningConfirm}
className="flex-1 px-4 py-2 bg-yellow-600 text-white rounded-lg font-medium hover:bg-yellow-700 transition-colors"
>
Continue Anyway
</button>
</div>
</div>
</div>
)}
{/* Hours Confirmation Modal (48+ hours) */}
{showHoursConfirm && hoursWarning && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900">Excessive Hours Warning</h2>
</div>
<div className="mb-6">
<p className="text-gray-700 mb-4">
<strong>{hoursWarning.employeeName}</strong> will have{' '}
<strong className="text-red-600">{hoursWarning.totalHours.toFixed(1)} hours</strong> scheduled
{view === 'week' ? ' this week' : ' this month'}.
</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800 font-medium mb-2">
This exceeds 48 hours and may violate labor regulations.
</p>
<p className="text-sm text-red-700">
Please verify that this schedule is correct and complies with applicable labor laws,
including overtime requirements and maximum work hour limits.
</p>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleHoursConfirmCancel}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleHoursConfirmAccept}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
I Understand, Save Anyway
</button>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && shiftToDelete && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900">Delete Shift</h2>
</div>
<div className="mb-6">
<p className="text-gray-700 mb-4">
Are you sure you want to delete this shift?
</p>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Employee:</span>
<span className="text-sm text-gray-900">{shiftToDelete.employee_name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Date:</span>
<span className="text-sm text-gray-900">
{format(parseISO(shiftToDelete.date), 'EEEE, MMMM d, yyyy')}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Time:</span>
<span className="text-sm text-gray-900">
{formatShiftTime(shiftToDelete)}
</span>
</div>
{shiftToDelete.location && (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600">Location:</span>
<span className="text-sm text-gray-900">{shiftToDelete.location}</span>
</div>
)}
</div>
</div>
<p className="text-sm text-red-600 mt-4 font-medium">
This action cannot be undone.
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleDeleteCancel}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
>
Delete Shift
</button>
</div>
</div>
</div>
)}
</div>
</Layout>
);
};

322
src/pages/Settings.tsx Normal file
View File

@ -0,0 +1,322 @@
import React, { useState, useEffect } from 'react';
import { Layout } from '../components/Layout/Layout';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { Settings as SettingsIcon, Lock, Bell, Moon, Globe, Save, AlertCircle } from 'lucide-react';
const API_URL = 'http://localhost:3001/api';
export const Settings: React.FC = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Load saved settings from localStorage
const loadSettings = () => {
const saved = localStorage.getItem('userSettings');
if (saved) {
try {
const settings = JSON.parse(saved);
if (settings.notifications) {
setNotifications(settings.notifications);
}
if (settings.display) {
setDisplay(settings.display);
}
} catch (e) {
console.error('Error loading settings:', e);
}
}
};
// Notification preferences
const [notifications, setNotifications] = useState({
emailNotifications: true,
pushNotifications: true,
scheduleReminders: true,
payrollAlerts: true,
documentExpiry: true,
});
// Display preferences
const [display, setDisplay] = useState({
theme: 'light',
language: 'en',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
dateFormat: 'MM/DD/YYYY',
timeFormat: '12h',
});
useEffect(() => {
loadSettings();
}, []);
const handleNotificationChange = (key: string) => {
setNotifications(prev => ({
...prev,
[key]: !prev[key as keyof typeof prev]
}));
};
const handleDisplayChange = (key: string, value: string) => {
setDisplay(prev => ({
...prev,
[key]: value
}));
};
const handleSave = async () => {
setLoading(true);
setError('');
setSuccess('');
try {
// In a real app, this would save to the backend
// For now, we'll just save to localStorage
localStorage.setItem('userSettings', JSON.stringify({
notifications,
display,
}));
setSuccess('Settings saved successfully!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
setError('Failed to save settings');
} finally {
setLoading(false);
}
};
const handleChangePassword = () => {
navigate('/change-password');
};
return (
<Layout>
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold text-gray-900">Settings</h1>
<button
onClick={handleSave}
disabled={loading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{loading ? 'Saving...' : 'Save Settings'}
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
{error}
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg mb-6">
{success}
</div>
)}
<div className="space-y-6">
{/* Security Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Lock className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-gray-900">Security</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Password</h3>
<p className="text-sm text-gray-600">Change your account password</p>
</div>
<button
onClick={handleChangePassword}
className="px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700"
>
Change Password
</button>
</div>
</div>
</div>
{/* Notification Settings */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Bell className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-gray-900">Notifications</h2>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Email Notifications</h3>
<p className="text-sm text-gray-600">Receive notifications via email</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.emailNotifications}
onChange={() => handleNotificationChange('emailNotifications')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Push Notifications</h3>
<p className="text-sm text-gray-600">Receive push notifications in browser</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.pushNotifications}
onChange={() => handleNotificationChange('pushNotifications')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Schedule Reminders</h3>
<p className="text-sm text-gray-600">Get reminders for upcoming shifts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.scheduleReminders}
onChange={() => handleNotificationChange('scheduleReminders')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Payroll Alerts</h3>
<p className="text-sm text-gray-600">Notifications about payroll updates</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.payrollAlerts}
onChange={() => handleNotificationChange('payrollAlerts')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 className="font-medium text-gray-900 mb-1">Document Expiry Warnings</h3>
<p className="text-sm text-gray-600">Alerts when documents are expiring</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.documentExpiry}
onChange={() => handleNotificationChange('documentExpiry')}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
</div>
</div>
{/* Display Preferences */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Moon className="w-6 h-6 text-primary-600" />
<h2 className="text-xl font-semibold text-gray-900">Display Preferences</h2>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Theme</label>
<select
value={display.theme}
onChange={(e) => handleDisplayChange('theme', 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"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto (System)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Language</label>
<select
value={display.language}
onChange={(e) => handleDisplayChange('language', 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"
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Timezone</label>
<select
value={display.timezone}
onChange={(e) => handleDisplayChange('timezone', 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"
>
<option value={Intl.DateTimeFormat().resolvedOptions().timeZone}>
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</option>
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Date Format</label>
<select
value={display.dateFormat}
onChange={(e) => handleDisplayChange('dateFormat', 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"
>
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Time Format</label>
<select
value={display.timeFormat}
onChange={(e) => handleDisplayChange('timeFormat', 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"
>
<option value="12h">12-hour (AM/PM)</option>
<option value="24h">24-hour</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
};

View File

@ -2,6 +2,11 @@ import React, { useState, useEffect } from 'react';
import { Clock, Lock } from 'lucide-react';
import { format } from 'date-fns';
interface ClockedInPeriod {
clockIn: string | null;
clockOut: string | null;
}
interface EmployeeStatus {
id: string;
name: string;
@ -14,6 +19,7 @@ interface EmployeeStatus {
scheduledEnd?: string;
isLate?: boolean;
leftEarly?: boolean;
clockedInPeriods?: ClockedInPeriod[];
}
export const TimeClock: React.FC = () => {
@ -112,31 +118,48 @@ export const TimeClock: React.FC = () => {
if (!time) return 0;
// Handle both "HH:mm" and "HH:mm:ss" formats
const timeOnly = time.includes(' ') ? time.split(' ')[1] : time;
const [hours, minutes] = timeOnly.split(':').map(Number);
const totalMinutes = hours * 60 + (minutes || 0);
const startMinutes = 8 * 60; // 8:00 AM
const endMinutes = 18 * 60; // 6:00 PM
const totalRange = endMinutes - startMinutes;
let timeOnly = time;
if (time.includes(' ')) {
timeOnly = time.split(' ')[1]; // Get time part after space
}
if (timeOnly.includes('T')) {
timeOnly = timeOnly.split('T')[1]; // Handle ISO format with T
}
if (totalMinutes < startMinutes) return 0;
if (totalMinutes > endMinutes) return 100;
// Extract hours and minutes - handle both 1-digit and 2-digit hours
const timeMatch = timeOnly.match(/(\d{1,2}):(\d{2})/);
if (!timeMatch) {
console.warn('Invalid time format:', time);
return 0;
}
return ((totalMinutes - startMinutes) / totalRange) * 100;
let hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
// Times from API should be in 24-hour format (HH:mm)
// Calculate position for 24-hour span (0:00 to 23:59)
const totalMinutes = hours * 60 + minutes;
const totalRange = 24 * 60; // 1440 minutes (24 hours)
// Calculate percentage position (0% = midnight, 100% = 11:59 PM)
const position = (totalMinutes / totalRange) * 100;
// Clamp to 0-100%
return Math.max(0, Math.min(100, position));
};
const getCurrentTimePosition = (): number => {
const hours = currentTime.getHours();
const minutes = currentTime.getMinutes();
const totalMinutes = hours * 60 + minutes;
const startMinutes = 8 * 60;
const endMinutes = 18 * 60;
const totalRange = endMinutes - startMinutes;
const now = new Date();
const hours = now.getHours();
const minutes = now.getMinutes();
const seconds = now.getSeconds();
// Include seconds for more precise positioning
const totalMinutes = hours * 60 + minutes + (seconds / 60);
const totalRange = 24 * 60; // 1440 minutes (24 hours)
if (totalMinutes < startMinutes) return 0;
if (totalMinutes > endMinutes) return 100;
return ((totalMinutes - startMinutes) / totalRange) * 100;
// Calculate percentage position for 24-hour span
const position = (totalMinutes / totalRange) * 100;
return Math.max(0, Math.min(100, position));
};
const getStatusColor = (employee: EmployeeStatus): string => {
@ -152,20 +175,51 @@ export const TimeClock: React.FC = () => {
};
const formatTime = (time: string | undefined): string => {
if (!time) return '';
try {
const [h, m] = time.split(':').map(Number);
const date = new Date();
date.setHours(h, m || 0, 0);
return format(date, 'h:mm a');
} catch {
return time;
if (!time) return 'N/A';
// Handle various time formats - extract just HH:mm
let timeStr = String(time);
// Extract time portion from datetime strings
if (timeStr.includes('T')) {
timeStr = timeStr.split('T')[1]; // Get part after T
} else if (timeStr.includes(' ')) {
timeStr = timeStr.split(' ')[1]; // Get part after space
}
// Remove milliseconds and timezone if present
timeStr = timeStr.split('.')[0]; // Remove .000
timeStr = timeStr.split('+')[0]; // Remove timezone
timeStr = timeStr.split('Z')[0]; // Remove Z
// Extract HH:mm
const timeMatch = timeStr.match(/(\d{2}):(\d{2})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1]);
const minutes = parseInt(timeMatch[2]);
const date = new Date();
date.setHours(hours, minutes, 0);
return format(date, 'h:mm a');
}
// Fallback: try to parse as HH:mm directly
if (timeStr.includes(':')) {
try {
const [h, m] = timeStr.split(':').map(Number);
const date = new Date();
date.setHours(h, m || 0, 0);
return format(date, 'h:mm a');
} catch {
return timeStr.substring(0, 5);
}
}
return 'N/A';
};
return (
<div className="min-h-screen bg-gray-50 p-6">
<div className="max-w-7xl mx-auto">
<div className="w-full">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between">
@ -181,19 +235,17 @@ export const TimeClock: React.FC = () => {
</div>
</div>
{/* Employee Grid - Large, clear blocks */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Employee Grid - Large, clear blocks - Maximum 4 per row, full width */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full">
{employees.map((employee) => {
const isClockedIn = employee.currentStatus === 'clocked_in' || employee.currentStatus === 'on_break';
const currentPosition = getCurrentTimePosition();
const clockInPosition = employee.clockInTime ? getTimeBarPosition(employee.clockInTime) : 100;
const clockOutPosition = employee.clockOutTime ? getTimeBarPosition(employee.clockOutTime) : 100;
return (
<div
key={employee.id}
onClick={() => handleEmployeeClick(employee)}
className="bg-white rounded-xl shadow-lg border-2 border-gray-300 hover:border-primary-500 hover:shadow-xl transition-all cursor-pointer p-6"
className="bg-white rounded-xl shadow-lg border-2 border-gray-300 hover:border-primary-500 hover:shadow-xl transition-all cursor-pointer p-6 w-full"
>
{/* Employee Name - Large and prominent */}
<div className="mb-4">
@ -206,60 +258,192 @@ export const TimeClock: React.FC = () => {
{/* Time Bar Container - Large and clear */}
<div className="relative h-16 bg-red-500 rounded-lg overflow-hidden border-2 border-gray-300">
{/* Hour markers */}
<div className="absolute inset-0 flex">
{Array.from({ length: 11 }, (_, i) => (
<div
key={i}
className="flex-1 border-r-2 border-red-600"
style={{ width: `${100 / 11}%` }}
></div>
))}
{/* Hour markers - positioned at actual hour positions for 24-hour span */}
<div className="absolute inset-0">
{Array.from({ length: 25 }, (_, i) => {
const hour = i; // 0 to 24 (midnight to midnight)
const position = (hour / 24) * 100;
return (
<div
key={i}
className={`absolute top-0 bottom-6 ${
hour % 6 === 0 ? 'border-r-2 border-red-600' : 'border-r border-red-400'
}`}
style={{ left: `${position}%` }}
></div>
);
})}
</div>
{/* Hour labels at bottom */}
<div className="absolute bottom-0 left-0 right-0 flex text-xs font-medium text-white bg-red-600 py-1">
{Array.from({ length: 11 }, (_, i) => (
<div
key={i}
className="flex-1 text-center"
style={{ width: `${100 / 11}%` }}
>
{8 + i}
</div>
))}
{/* Hour labels at bottom - show every 3 hours for readability */}
<div className="absolute bottom-0 left-0 right-0 h-6 flex items-center text-xs font-medium text-white bg-red-600 px-1">
{Array.from({ length: 9 }, (_, i) => {
const hour = i * 3; // 0, 3, 6, 9, 12, 15, 18, 21, 24
const position = (hour / 24) * 100;
const label = hour === 0 ? '12 AM' :
hour === 12 ? '12 PM' :
hour < 12 ? `${hour} AM` :
hour === 24 ? '12 AM' :
`${hour - 12} PM`;
return (
<div
key={i}
className="absolute text-center whitespace-nowrap"
style={{
left: `${position}%`,
transform: 'translateX(-50%)'
}}
>
{label}
</div>
);
})}
</div>
{/* Green bar for clocked in period - always show if they clocked in today */}
{employee.clockInTime && (
{/* Green bars for all clocked in periods - show all historical periods */}
{employee.clockedInPeriods && employee.clockedInPeriods.length > 0 ? (
<>
{employee.clockOutTime ? (
// Employee clocked in and out - green from clock in to clock out (historical)
<div
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-5"
style={{
left: `${clockInPosition}%`,
width: `${clockOutPosition - clockInPosition}%`,
minWidth: '1%',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
</div>
) : (
// Employee clocked in but not out - green from clock in to current time
<div
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-5"
style={{
left: `${clockInPosition}%`,
width: `${currentPosition - clockInPosition}%`,
minWidth: '2%',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
</div>
)}
{employee.clockedInPeriods.map((period, index) => {
if (!period.clockIn) return null;
// Calculate positions using the same method
let periodClockInPos = getTimeBarPosition(period.clockIn);
const periodClockOutPos = period.clockOut ? getTimeBarPosition(period.clockOut) : null;
// For the current/latest period, extend to current time if not clocked out
const isCurrentPeriod = index === employee.clockedInPeriods!.length - 1 && !period.clockOut;
// Debug: log positions for troubleshooting (commented out for production)
// console.log(`Period ${index} (${isCurrentPeriod ? 'CURRENT' : 'COMPLETED'}): clockIn=${period.clockIn} (${periodClockInPos.toFixed(2)}%), clockOut=${period.clockOut || 'null'} (${periodClockOutPos?.toFixed(2) || 'N/A'}%)`);
let endPosition: number;
if (isCurrentPeriod) {
// Current period: extend to current time
// But if clock-in is after current time (shouldn't happen), just show a marker
if (periodClockInPos > currentPosition) {
// Clock-in is in the future - this shouldn't happen, but handle it
endPosition = periodClockInPos + 0.5; // Just show a small marker
} else {
endPosition = Math.min(currentPosition, 100);
}
} else if (periodClockOutPos !== null) {
// Completed period: use clock-out position
endPosition = periodClockOutPos;
} else {
// No clock-out but not current period (shouldn't happen, but handle it)
endPosition = currentPosition;
}
// Calculate width - ensure it's positive and positions are valid
let width = endPosition - periodClockInPos;
if (width < 0) {
// If end is before start, this period is invalid or in the future
// Just show a marker at the clock-in position
return (
<div key={index}>
<div
className="absolute top-0 bottom-6 w-1 bg-yellow-500 z-20"
style={{ left: `${periodClockInPos}%` }}
title={`Clock-in time ${formatTime(period.clockIn)} is after current time`}
>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-yellow-500 rounded-full border-2 border-white"></div>
</div>
</div>
);
}
const minVisibleWidth = 0.2; // Minimum width to be visible (0.2%)
// For very short periods (less than 0.2%), just show a marker
if (width < minVisibleWidth && !isCurrentPeriod && periodClockOutPos !== null) {
// Very short completed period - just show markers
return (
<div key={index}>
{/* Clock-in marker */}
<div
className="absolute top-0 bottom-6 w-1 bg-green-700 z-20"
style={{ left: `${periodClockInPos}%` }}
title={`Brief period: ${formatTime(period.clockIn)} to ${formatTime(period.clockOut)}`}
>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-green-700 rounded-full border-2 border-white"></div>
</div>
</div>
);
}
// Render green bar for this period
// Ensure we use the correct left position (should always be clockInPos)
const finalLeft = periodClockInPos;
const finalWidth = Math.max(minVisibleWidth, width);
return (
<div key={index}>
{/* Green bar for this period */}
<div
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-10"
style={{
left: `${finalLeft}%`,
width: `${finalWidth}%`,
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
</div>
{/* Clock-in marker for this period */}
<div
className="absolute top-0 bottom-6 w-0.5 bg-green-700 z-20"
style={{ left: `${periodClockInPos}%` }}
title={`Clocked in at ${formatTime(period.clockIn)}`}
>
<div className="absolute -top-1 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-green-700 rounded-full border border-white"></div>
</div>
{/* Clock-out marker if applicable and different from clock-in */}
{period.clockOut && periodClockOutPos !== null && Math.abs(periodClockOutPos - periodClockInPos) > 0.1 && (
<div
className="absolute top-0 bottom-6 w-0.5 bg-green-600 z-20"
style={{ left: `${periodClockOutPos}%` }}
title={`Clocked out at ${formatTime(period.clockOut)}`}
>
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2 w-2 h-2 bg-green-600 rounded-full border border-white"></div>
</div>
)}
</div>
);
})}
</>
)}
) : employee.clockInTime && employee.clockInTime.length >= 5 && employee.clockInTime.includes(':') ? (
// Fallback: show single period if clockedInPeriods not available
(() => {
const fallbackClockInPos = getTimeBarPosition(employee.clockInTime);
const fallbackClockOutPos = employee.clockOutTime && employee.clockOutTime.length >= 5 && employee.clockOutTime.includes(':')
? getTimeBarPosition(employee.clockOutTime)
: null;
return (
<>
{fallbackClockOutPos !== null ? (
<div
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-10"
style={{
left: `${fallbackClockInPos}%`,
width: `${Math.max(0.2, fallbackClockOutPos - fallbackClockInPos)}%`,
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
</div>
) : (
<div
className="absolute top-0 bottom-6 bg-green-500 opacity-90 z-10"
style={{
left: `${fallbackClockInPos}%`,
width: `${Math.max(0.2, currentPosition - fallbackClockInPos)}%`,
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black opacity-10"></div>
</div>
)}
</>
);
})()
) : null}
{/* Current time indicator */}
<div

View File

@ -1,69 +1,161 @@
import React, { useState, useEffect } from 'react';
import { Layout } from '../components/Layout/Layout';
import { Users, Edit, Trash2, Plus, Search, Filter, Save, X } from 'lucide-react';
import { mockDatabase } from '../data/mockDatabase';
import { User, UserRole } from '../types';
import { useAuth } from '../context/AuthContext';
const API_URL = 'http://localhost:3001/api';
export const UserManagement: React.FC = () => {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
role: 'employee' as UserRole,
jobTitle: '',
department: '',
phone: '',
address: '',
});
useEffect(() => {
loadUsers();
}, []);
const loadUsers = () => {
setUsers(mockDatabase.getUsers());
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
};
const loadUsers = async () => {
try {
setLoading(true);
const response = await fetch(`${API_URL}/users`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Failed to load users');
}
const data = await response.json();
// Map API response to User type
const mappedUsers: User[] = data.map((u: any) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
}));
setUsers(mappedUsers);
} catch (error) {
console.error('Error loading users:', error);
setError('Failed to load users');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingUser(null);
setFormData({ name: '', email: '', role: 'employee' });
setFormData({ name: '', email: '', role: 'employee', jobTitle: '', department: '', phone: '', address: '' });
setShowForm(true);
setError('');
};
const handleEdit = (user: User) => {
setEditingUser(user);
setFormData({ name: user.name, email: user.email, role: user.role });
setFormData({
name: user.name,
email: user.email,
role: user.role,
jobTitle: '',
department: '',
phone: '',
address: '',
});
setShowForm(true);
setError('');
};
const handleDelete = (id: string) => {
if (window.confirm('Are you sure you want to delete this user?')) {
if (mockDatabase.deleteUser(id)) {
loadUsers();
const handleDelete = async (id: string) => {
if (!window.confirm('Are you sure you want to delete this user?')) {
return;
}
try {
setLoading(true);
const response = await fetch(`${API_URL}/users/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete user');
}
await loadUsers();
} catch (error: any) {
console.error('Error deleting user:', error);
setError(error.message || 'Failed to delete user');
} finally {
setLoading(false);
}
};
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (editingUser) {
// Update existing user
const updated = mockDatabase.updateUser(editingUser.id, formData);
if (updated) {
loadUsers();
setError('');
setLoading(true);
try {
if (editingUser) {
// Update existing user
const response = await fetch(`${API_URL}/users/${editingUser.id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update user');
}
await loadUsers();
setShowForm(false);
setEditingUser(null);
}
} else {
// Create new user
const newUser = mockDatabase.createUser(formData);
if (newUser) {
loadUsers();
} else {
// Create new user
const response = await fetch(`${API_URL}/users`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(formData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create user');
}
await loadUsers();
setShowForm(false);
setFormData({ name: '', email: '', role: 'employee' });
setFormData({ name: '', email: '', role: 'employee', jobTitle: '', department: '', phone: '', address: '' });
}
} catch (error: any) {
console.error('Error saving user:', error);
setError(error.message || 'Failed to save user');
} finally {
setLoading(false);
}
};
@ -106,19 +198,36 @@ export const UserManagement: React.FC = () => {
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.map((user) => (
{loading && !users.length ? (
<div className="p-8 text-center text-gray-500">Loading users...</div>
) : (
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-gray-500">
No users found
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
@ -158,9 +267,11 @@ export const UserManagement: React.FC = () => {
</div>
</td>
</tr>
))}
</tbody>
</table>
))
)}
</tbody>
</table>
)}
</div>
{/* Create/Edit Form Modal */}
@ -182,6 +293,12 @@ export const UserManagement: React.FC = () => {
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@ -227,6 +344,58 @@ export const UserManagement: React.FC = () => {
</select>
</div>
{formData.role !== 'admin' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Job Title
</label>
<input
type="text"
value={formData.jobTitle}
onChange={(e) => setFormData({ ...formData, jobTitle: 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Department
</label>
<input
type="text"
value={formData.department}
onChange={(e) => setFormData({ ...formData, department: 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Address
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, 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"
/>
</div>
</>
)}
<div className="flex gap-2 pt-4">
<button
type="button"
@ -240,10 +409,11 @@ export const UserManagement: React.FC = () => {
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2"
disabled={loading}
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{editingUser ? 'Update' : 'Create'}
{loading ? 'Saving...' : (editingUser ? 'Update' : 'Create')}
</button>
</div>
</form>

View File

@ -8,6 +8,7 @@ export interface User {
email: string;
role: UserRole;
avatar?: string;
mustChangePassword?: boolean;
}
export interface Timecard {