updated commit
This commit is contained in:
parent
5958758b3f
commit
6dc86dac45
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
129
server/src/routes/employees.js
Normal file
129
server/src/routes/employees.js
Normal 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
306
server/src/routes/shifts.js
Normal 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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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) => {
|
||||
|
||||
37
src/App.tsx
37
src/App.tsx
@ -6,6 +6,7 @@ import { Dashboard } from './pages/Dashboard';
|
||||
import { ClockInOut } from './pages/ClockInOut';
|
||||
import { Timecards } from './pages/Timecards';
|
||||
import { Schedule } from './pages/Schedule';
|
||||
import { ScheduleManagement } from './pages/ScheduleManagement';
|
||||
import { TeamTimecards } from './pages/TeamTimecards';
|
||||
import { TeamSchedules } from './pages/TeamSchedules';
|
||||
import { Receipts } from './pages/Receipts';
|
||||
@ -17,6 +18,9 @@ import { SystemSettings } from './pages/SystemSettings';
|
||||
import { AuditLogs } from './pages/AuditLogs';
|
||||
import { UserManagement } from './pages/UserManagement';
|
||||
import { Placeholder } from './pages/Placeholder';
|
||||
import { ChangePassword } from './pages/ChangePassword';
|
||||
import { MyProfile } from './pages/MyProfile';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
|
||||
@ -26,11 +30,34 @@ function LoginRoute() {
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated, mustChangePassword } = useAuth();
|
||||
|
||||
return (
|
||||
<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={
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
31
src/main.tsx
31
src/main.tsx
@ -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 = `
|
||||
|
||||
212
src/pages/ChangePassword.tsx
Normal file
212
src/pages/ChangePassword.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
322
src/pages/MyProfile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
898
src/pages/ScheduleManagement.tsx
Normal file
898
src/pages/ScheduleManagement.tsx
Normal 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
322
src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -8,6 +8,7 @@ export interface User {
|
||||
email: string;
|
||||
role: UserRole;
|
||||
avatar?: string;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
export interface Timecard {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user