740 lines
33 KiB
TypeScript
740 lines
33 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
|
|
import { json, redirect } from "@remix-run/node";
|
|
import { Form, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
|
|
import { requireAuthLevel } from "~/utils/auth.server";
|
|
import DashboardLayout from "~/components/DashboardLayout";
|
|
import FormModal from "~/components/FormModal";
|
|
import Toast from "~/components/Toast";
|
|
import { useState, useEffect } from "react";
|
|
import bcrypt from "bcryptjs";
|
|
import { prisma } from "~/utils/db.server";
|
|
|
|
export const meta: MetaFunction = () => [{ title: "Employee Management - Phosphat Report" }];
|
|
|
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|
const user = await requireAuthLevel(request, 2);
|
|
|
|
// If user is level 2 (Admin), they can only see employees with level <= 2
|
|
// If user is level 3 (Super Admin), they can see all employees
|
|
const whereClause = user.authLevel === 2
|
|
? { authLevel: { lte: 2 } } // Level 2 users can only see level 1 and 2
|
|
: {}; // Level 3 users can see all levels
|
|
|
|
const employees = await prisma.employee.findMany({
|
|
where: whereClause,
|
|
orderBy: [{ authLevel: 'asc' }, { name: 'asc' }],
|
|
include: {
|
|
_count: {
|
|
select: { reports: true }
|
|
}
|
|
}
|
|
});
|
|
|
|
return json({ user, employees });
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
const user = await requireAuthLevel(request, 2);
|
|
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
const id = formData.get("id");
|
|
const name = formData.get("name");
|
|
const username = formData.get("username");
|
|
const email = formData.get("email");
|
|
const password = formData.get("password");
|
|
const authLevel = formData.get("authLevel");
|
|
const status = formData.get("status");
|
|
|
|
if (intent === "create") {
|
|
if (typeof name !== "string" || name.length === 0) {
|
|
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
|
}
|
|
if (typeof username !== "string" || username.length === 0) {
|
|
return json({ errors: { username: "Username is required" } }, { status: 400 });
|
|
}
|
|
if (typeof email !== "string" || email.length === 0) {
|
|
return json({ errors: { email: "Email is required" } }, { status: 400 });
|
|
}
|
|
// Basic email validation
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return json({ errors: { email: "Please enter a valid email address" } }, { status: 400 });
|
|
}
|
|
if (typeof password !== "string" || password.length < 6) {
|
|
return json({ errors: { password: "Password must be at least 6 characters" } }, { status: 400 });
|
|
}
|
|
if (typeof authLevel !== "string" || !["1", "2", "3"].includes(authLevel)) {
|
|
return json({ errors: { authLevel: "Valid auth level is required (1, 2, or 3)" } }, { status: 400 });
|
|
}
|
|
|
|
// Level 2 users cannot create Level 3 employees
|
|
if (user.authLevel === 2 && parseInt(authLevel) === 3) {
|
|
return json({ errors: { authLevel: "You don't have permission to create Super Admin users" } }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const hashedPassword = bcrypt.hashSync(password, 10);
|
|
await prisma.employee.create({
|
|
data: {
|
|
name,
|
|
username,
|
|
email,
|
|
password: hashedPassword,
|
|
authLevel: parseInt(authLevel),
|
|
status: 'active'
|
|
}
|
|
});
|
|
return json({ success: "Employee created successfully!" });
|
|
} catch (error) {
|
|
return json({ errors: { form: "Username or email already exists" } }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
if (intent === "update") {
|
|
if (typeof name !== "string" || name.length === 0) {
|
|
return json({ errors: { name: "Name is required" } }, { status: 400 });
|
|
}
|
|
if (typeof username !== "string" || username.length === 0) {
|
|
return json({ errors: { username: "Username is required" } }, { status: 400 });
|
|
}
|
|
if (typeof email !== "string" || email.length === 0) {
|
|
return json({ errors: { email: "Email is required" } }, { status: 400 });
|
|
}
|
|
// Basic email validation
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(email)) {
|
|
return json({ errors: { email: "Please enter a valid email address" } }, { status: 400 });
|
|
}
|
|
if (typeof authLevel !== "string" || !["1", "2", "3"].includes(authLevel)) {
|
|
return json({ errors: { authLevel: "Valid auth level is required (1, 2, or 3)" } }, { status: 400 });
|
|
}
|
|
if (typeof id !== "string") {
|
|
return json({ errors: { form: "Invalid employee ID" } }, { status: 400 });
|
|
}
|
|
|
|
// Check if the employee being updated exists and if current user can edit them
|
|
const existingEmployee = await prisma.employee.findUnique({
|
|
where: { id: parseInt(id) },
|
|
select: { authLevel: true }
|
|
});
|
|
|
|
if (!existingEmployee) {
|
|
return json({ errors: { form: "Employee not found" } }, { status: 404 });
|
|
}
|
|
|
|
// Level 2 users cannot edit Level 3 employees
|
|
if (user.authLevel === 2 && existingEmployee.authLevel === 3) {
|
|
return json({ errors: { form: "You don't have permission to edit Super Admin users" } }, { status: 403 });
|
|
}
|
|
|
|
// Level 2 users cannot promote someone to Level 3
|
|
if (user.authLevel === 2 && parseInt(authLevel) === 3) {
|
|
return json({ errors: { authLevel: "You don't have permission to create Super Admin users" } }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const updateData: any = {
|
|
name,
|
|
username,
|
|
email,
|
|
authLevel: parseInt(authLevel)
|
|
};
|
|
|
|
// Add status if provided (but prevent users from changing their own status)
|
|
if (typeof status === "string" && ["active", "inactive"].includes(status)) {
|
|
if (parseInt(id) !== user.id) {
|
|
updateData.status = status;
|
|
}
|
|
}
|
|
|
|
// Only update password if provided
|
|
if (typeof password === "string" && password.length >= 6) {
|
|
updateData.password = bcrypt.hashSync(password, 10);
|
|
} else if (typeof password === "string" && password.length > 0 && password.length < 6) {
|
|
return json({ errors: { password: "Password must be at least 6 characters" } }, { status: 400 });
|
|
}
|
|
|
|
await prisma.employee.update({
|
|
where: { id: parseInt(id) },
|
|
data: updateData
|
|
});
|
|
return json({ success: "Employee updated successfully!" });
|
|
} catch (error) {
|
|
return json({ errors: { form: "Username or email already exists" } }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
if (intent === "toggleStatus") {
|
|
if (typeof id !== "string") {
|
|
return json({ errors: { form: "Invalid employee ID" } }, { status: 400 });
|
|
}
|
|
|
|
const employeeId = parseInt(id);
|
|
|
|
// Prevent users from changing their own status
|
|
if (employeeId === user.id) {
|
|
return json({ errors: { form: "You cannot change your own status" } }, { status: 403 });
|
|
}
|
|
|
|
// Check if the employee exists and if current user can edit them
|
|
const existingEmployee = await prisma.employee.findUnique({
|
|
where: { id: employeeId },
|
|
select: { authLevel: true, status: true }
|
|
});
|
|
|
|
if (!existingEmployee) {
|
|
return json({ errors: { form: "Employee not found" } }, { status: 404 });
|
|
}
|
|
|
|
// Level 2 users cannot edit Level 3 employees
|
|
if (user.authLevel === 2 && existingEmployee.authLevel === 3) {
|
|
return json({ errors: { form: "You don't have permission to edit Super Admin users" } }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
const newStatus = existingEmployee.status === 'active' ? 'inactive' : 'active';
|
|
await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { status: newStatus }
|
|
});
|
|
return json({ success: `Employee status changed to ${newStatus}!` });
|
|
} catch (error) {
|
|
return json({ errors: { form: "Failed to update employee status" } }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
if (intent === "delete") {
|
|
if (typeof id !== "string") {
|
|
return json({ errors: { form: "Invalid employee ID" } }, { status: 400 });
|
|
}
|
|
|
|
// Check if the employee being deleted exists and if current user can delete them
|
|
const existingEmployee = await prisma.employee.findUnique({
|
|
where: { id: parseInt(id) },
|
|
select: { authLevel: true }
|
|
});
|
|
|
|
if (!existingEmployee) {
|
|
return json({ errors: { form: "Employee not found" } }, { status: 404 });
|
|
}
|
|
|
|
// Level 2 users cannot delete Level 3 employees
|
|
if (user.authLevel === 2 && existingEmployee.authLevel === 3) {
|
|
return json({ errors: { form: "You don't have permission to delete Super Admin users" } }, { status: 403 });
|
|
}
|
|
|
|
try {
|
|
await prisma.employee.delete({
|
|
where: { id: parseInt(id) }
|
|
});
|
|
return json({ success: "Employee deleted successfully!" });
|
|
} catch (error) {
|
|
return json({ errors: { form: "Cannot delete employee with existing reports" } }, { status: 400 });
|
|
}
|
|
}
|
|
|
|
return json({ errors: { form: "Invalid action" } }, { status: 400 });
|
|
};
|
|
|
|
export default function Employees() {
|
|
const { user, employees } = useLoaderData<typeof loader>();
|
|
const actionData = useActionData<typeof action>();
|
|
const navigation = useNavigation();
|
|
const [editingEmployee, setEditingEmployee] = useState<{ id: number; name: string; username: string; email: string; authLevel: number; status: string } | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
|
|
|
const isSubmitting = navigation.state === "submitting";
|
|
const isEditing = editingEmployee !== null;
|
|
|
|
// Handle success/error messages
|
|
useEffect(() => {
|
|
if (actionData?.success) {
|
|
setToast({ message: actionData.success, type: "success" });
|
|
setShowModal(false);
|
|
setEditingEmployee(null);
|
|
} else if (actionData?.errors?.form) {
|
|
setToast({ message: actionData.errors.form, type: "error" });
|
|
}
|
|
}, [actionData]);
|
|
|
|
const handleEdit = (employee: { id: number; name: string; username: string; email: string; authLevel: number; status: string }) => {
|
|
setEditingEmployee(employee);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleAdd = () => {
|
|
setEditingEmployee(null);
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setShowModal(false);
|
|
setEditingEmployee(null);
|
|
};
|
|
|
|
const getAuthLevelBadge = (authLevel: number) => {
|
|
const colors = {
|
|
1: "bg-yellow-100 text-yellow-800",
|
|
2: "bg-green-100 text-green-800",
|
|
3: "bg-blue-100 text-blue-800"
|
|
};
|
|
return colors[authLevel as keyof typeof colors] || "bg-gray-100 text-gray-800";
|
|
};
|
|
|
|
const getAuthLevelText = (authLevel: number) => {
|
|
const levels = {
|
|
1: "User",
|
|
2: "Admin",
|
|
3: "Super Admin"
|
|
};
|
|
return levels[authLevel as keyof typeof levels] || "Unknown";
|
|
};
|
|
|
|
const getAuthLevelIcon = (authLevel: number) => {
|
|
switch (authLevel) {
|
|
case 1:
|
|
return (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
);
|
|
case 2:
|
|
return (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
);
|
|
case 3:
|
|
return (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
);
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
return status === 'active'
|
|
? "bg-green-100 text-green-800"
|
|
: "bg-red-100 text-red-800";
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
return status === 'active' ? (
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<DashboardLayout user={user}>
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
|
|
<div>
|
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">Employee Management</h1>
|
|
<p className="mt-1 text-sm text-gray-600">Manage system users and their access levels</p>
|
|
</div>
|
|
<button
|
|
onClick={handleAdd}
|
|
className="inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200"
|
|
>
|
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Add New Employee
|
|
</button>
|
|
</div>
|
|
|
|
{/* Employees Table - Desktop */}
|
|
<div className="bg-white shadow-sm rounded-lg overflow-hidden">
|
|
<div className="hidden lg:block">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Employee
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Username
|
|
</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">
|
|
Access Level
|
|
</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">
|
|
Reports Count
|
|
</th>
|
|
<th className="px-6 py-3 text-right 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((employee) => (
|
|
<tr key={employee.id} className="hover:bg-gray-50 transition-colors duration-150">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 h-10 w-10">
|
|
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getAuthLevelBadge(employee.authLevel)}`}>
|
|
{getAuthLevelIcon(employee.authLevel)}
|
|
</div>
|
|
</div>
|
|
<div className="ml-4">
|
|
<div className="text-sm font-medium text-gray-900">{employee.name}</div>
|
|
{/* <div className="text-sm text-gray-500">ID #{employee.id}</div> */}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900 font-mono">{employee.username}</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">{employee.email}</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getAuthLevelBadge(employee.authLevel)}`}>
|
|
Level {employee.authLevel} - {getAuthLevelText(employee.authLevel)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadge(employee.status)}`}>
|
|
{getStatusIcon(employee.status)}
|
|
<span className="ml-1 capitalize">{employee.status}</span>
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
{employee._count.reports} reports
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div className="flex justify-end space-x-2">
|
|
<button
|
|
onClick={() => handleEdit(employee)}
|
|
className="text-indigo-600 hover:text-indigo-900 transition-colors duration-150"
|
|
>
|
|
Edit
|
|
</button>
|
|
{employee.id !== user.id && (
|
|
<>
|
|
<Form method="post" className="inline">
|
|
<input type="hidden" name="intent" value="toggleStatus" />
|
|
<input type="hidden" name="id" value={employee.id} />
|
|
<button
|
|
type="submit"
|
|
className={`transition-colors duration-150 ${
|
|
employee.status === 'active'
|
|
? 'text-orange-600 hover:text-orange-900'
|
|
: 'text-green-600 hover:text-green-900'
|
|
}`}
|
|
>
|
|
{employee.status === 'active' ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
</Form>
|
|
<Form method="post" className="inline">
|
|
<input type="hidden" name="intent" value="delete" />
|
|
<input type="hidden" name="id" value={employee.id} />
|
|
<button
|
|
type="submit"
|
|
onClick={(e) => {
|
|
if (!confirm("Are you sure you want to delete this employee?")) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
className="text-red-600 hover:text-red-900 transition-colors duration-150"
|
|
>
|
|
Delete
|
|
</button>
|
|
</Form>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Employees Cards - Mobile */}
|
|
<div className="lg:hidden">
|
|
<div className="space-y-4 p-4">
|
|
{employees.map((employee) => (
|
|
<div key={employee.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex items-center">
|
|
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${getAuthLevelBadge(employee.authLevel)}`}>
|
|
{getAuthLevelIcon(employee.authLevel)}
|
|
</div>
|
|
<div className="ml-3">
|
|
<div className="text-sm font-medium text-gray-900">{employee.name}</div>
|
|
<div className="text-xs text-gray-500 font-mono">{employee.username}</div>
|
|
</div>
|
|
</div>
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadge(employee.status)}`}>
|
|
{getStatusIcon(employee.status)}
|
|
<span className="ml-1 capitalize">{employee.status}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-2 mb-4">
|
|
<div className="flex justify-between">
|
|
<span className="text-xs font-medium text-gray-500">Email:</span>
|
|
<span className="text-xs text-gray-900 truncate ml-2">{employee.email}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-xs font-medium text-gray-500">Access Level:</span>
|
|
<span className="text-xs text-gray-900">Level {employee.authLevel} - {getAuthLevelText(employee.authLevel)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-xs font-medium text-gray-500">Reports:</span>
|
|
<span className="text-xs text-gray-900">{employee._count.reports} reports</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col space-y-2">
|
|
<button
|
|
onClick={() => handleEdit(employee)}
|
|
className="w-full text-center px-3 py-2 text-sm text-indigo-600 bg-indigo-50 rounded-md hover:bg-indigo-100 transition-colors duration-150"
|
|
>
|
|
Edit Employee
|
|
</button>
|
|
{employee.id !== user.id && (
|
|
<div className="flex space-x-2">
|
|
<Form method="post" className="flex-1">
|
|
<input type="hidden" name="intent" value="toggleStatus" />
|
|
<input type="hidden" name="id" value={employee.id} />
|
|
<button
|
|
type="submit"
|
|
className={`w-full text-center px-3 py-2 text-sm rounded-md transition-colors duration-150 ${
|
|
employee.status === 'active'
|
|
? 'text-orange-600 bg-orange-50 hover:bg-orange-100'
|
|
: 'text-green-600 bg-green-50 hover:bg-green-100'
|
|
}`}
|
|
>
|
|
{employee.status === 'active' ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
</Form>
|
|
<Form method="post" className="flex-1">
|
|
<input type="hidden" name="intent" value="delete" />
|
|
<input type="hidden" name="id" value={employee.id} />
|
|
<button
|
|
type="submit"
|
|
onClick={(e) => {
|
|
if (!confirm("Are you sure you want to delete this employee?")) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
className="w-full text-center px-3 py-2 text-sm text-red-600 bg-red-50 rounded-md hover:bg-red-100 transition-colors duration-150"
|
|
>
|
|
Delete
|
|
</button>
|
|
</Form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{employees.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
|
</svg>
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No employees</h3>
|
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first employee.</p>
|
|
<div className="mt-6">
|
|
<button
|
|
onClick={handleAdd}
|
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
|
>
|
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Add Employee
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Form Modal */}
|
|
<FormModal
|
|
isOpen={showModal}
|
|
onClose={handleCloseModal}
|
|
title={isEditing ? "Edit Employee" : "Add New Employee"}
|
|
isSubmitting={isSubmitting}
|
|
submitText={isEditing ? "Update Employee" : "Create Employee"}
|
|
>
|
|
<Form method="post" id="modal-form" className="space-y-4">
|
|
<input type="hidden" name="intent" value={isEditing ? "update" : "create"} />
|
|
{isEditing && <input type="hidden" name="id" value={editingEmployee?.id} />}
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Full Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="name"
|
|
id="name"
|
|
required
|
|
defaultValue={editingEmployee?.name || ""}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
placeholder="Enter full name"
|
|
/>
|
|
{actionData?.errors?.name && (
|
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Username
|
|
</label>
|
|
<input
|
|
type="text"
|
|
name="username"
|
|
id="username"
|
|
required
|
|
defaultValue={editingEmployee?.username || ""}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
placeholder="Enter username"
|
|
/>
|
|
{actionData?.errors?.username && (
|
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.username}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Email Address
|
|
</label>
|
|
<input
|
|
type="email"
|
|
name="email"
|
|
id="email"
|
|
required
|
|
defaultValue={editingEmployee?.email || ""}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
placeholder="Enter email address"
|
|
/>
|
|
{actionData?.errors?.email && (
|
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.email}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Password {isEditing && <span className="text-gray-500">(leave blank to keep current)</span>}
|
|
</label>
|
|
<input
|
|
type="password"
|
|
name="password"
|
|
id="password"
|
|
required={!isEditing}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
placeholder={isEditing ? "Enter new password" : "Enter password"}
|
|
/>
|
|
{actionData?.errors?.password && (
|
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.password}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="authLevel" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Access Level
|
|
</label>
|
|
<select
|
|
name="authLevel"
|
|
id="authLevel"
|
|
required
|
|
defaultValue={editingEmployee?.authLevel || ""}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
>
|
|
<option value="">Select access level</option>
|
|
<option value="1">Level 1 - User (Basic Access)</option>
|
|
<option value="2">Level 2 - Admin (Management Access)</option>
|
|
{user.authLevel === 3 && (
|
|
<option value="3">Level 3 - Super Admin (Full Access)</option>
|
|
)}
|
|
</select>
|
|
{actionData?.errors?.authLevel && (
|
|
<p className="mt-1 text-sm text-red-600">{actionData.errors.authLevel}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isEditing && editingEmployee?.id !== user.id && (
|
|
<div>
|
|
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-2">
|
|
Status
|
|
</label>
|
|
<select
|
|
name="status"
|
|
id="status"
|
|
defaultValue={editingEmployee?.status || "active"}
|
|
className="block w-full border-gray-300 h-9 p-2 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
|
>
|
|
<option value="active">Active</option>
|
|
<option value="inactive">Inactive</option>
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isEditing && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
|
|
<div className="flex">
|
|
<svg className="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
</svg>
|
|
<div className="ml-3">
|
|
<p className="text-sm text-yellow-700">
|
|
Leave password field empty to keep the current password unchanged.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
</FormModal>
|
|
|
|
{/* Toast Notifications */}
|
|
{toast && (
|
|
<Toast
|
|
message={toast.message}
|
|
type={toast.type}
|
|
onClose={() => setToast(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
} |