(Feat): Initial Commit
This commit is contained in:
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
76
src/App.tsx
Normal file
76
src/App.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { Sidebar } from './components/layout/Sidebar';
|
||||
import { Header } from './components/layout/Header';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { UsersPage } from './pages/UsersPage';
|
||||
import { WorkAllocationPage } from './pages/WorkAllocationPage';
|
||||
import { AttendancePage } from './pages/AttendancePage';
|
||||
import { RatesPage } from './pages/RatesPage';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
|
||||
type PageType = 'dashboard' | 'users' | 'allocation' | 'attendance' | 'rates';
|
||||
|
||||
const AppContent: React.FC = () => {
|
||||
const [activePage, setActivePage] = useState<PageType>('dashboard');
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
const renderPage = () => {
|
||||
switch (activePage) {
|
||||
case 'dashboard':
|
||||
return <DashboardPage />;
|
||||
case 'users':
|
||||
return <UsersPage />;
|
||||
case 'allocation':
|
||||
return <WorkAllocationPage />;
|
||||
case 'attendance':
|
||||
return <AttendancePage />;
|
||||
case 'rates':
|
||||
return <RatesPage />;
|
||||
default:
|
||||
return <DashboardPage />;
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login page if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
// Show main app if authenticated
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<Sidebar activePage={activePage} onNavigate={(page) => setActivePage(page as PageType)} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{renderPage()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
205
src/components/layout/Header.tsx
Normal file
205
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Bell, LogOut, X, Camera, Shield, User, Mail, Building2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useDepartments } from '../../hooks/useDepartments';
|
||||
|
||||
interface ProfilePopupProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
// Permission definitions for each role
|
||||
const rolePermissions: Record<string, { title: string; permissions: string[] }> = {
|
||||
Supervisor: {
|
||||
title: 'Supervisor Permissions',
|
||||
permissions: [
|
||||
'View and manage employees in your department',
|
||||
'Create and manage work allocations',
|
||||
'Set contractor rates for your department',
|
||||
'View attendance records',
|
||||
'Manage check-in/check-out for employees',
|
||||
]
|
||||
},
|
||||
Employee: {
|
||||
title: 'Employee Permissions',
|
||||
permissions: [
|
||||
'View your work allocations',
|
||||
'View your attendance records',
|
||||
'Check-in and check-out',
|
||||
'View assigned tasks',
|
||||
]
|
||||
},
|
||||
Contractor: {
|
||||
title: 'Contractor Permissions',
|
||||
permissions: [
|
||||
'View assigned work allocations',
|
||||
'View your rate configurations',
|
||||
'Track work completion status',
|
||||
]
|
||||
},
|
||||
SuperAdmin: {
|
||||
title: 'Super Admin Permissions',
|
||||
permissions: [
|
||||
'Full system access',
|
||||
'Manage all users and departments',
|
||||
'Configure all contractor rates',
|
||||
'View all work allocations and reports',
|
||||
'System configuration and settings',
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const ProfilePopup: React.FC<ProfilePopupProps> = ({ isOpen, onClose, onLogout }) => {
|
||||
const { user } = useAuth();
|
||||
const { departments } = useDepartments();
|
||||
const [showPermissions, setShowPermissions] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const userDepartment = departments.find(d => d.id === user?.department_id);
|
||||
const userPermissions = rolePermissions[user?.role || 'Employee'];
|
||||
|
||||
return (
|
||||
<div className="absolute right-4 top-16 w-[380px] bg-gradient-to-b from-slate-50 to-white rounded-2xl shadow-2xl z-50 border border-gray-200 overflow-hidden font-sans text-sm text-gray-800">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-teal-600 to-teal-500 px-6 py-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1" />
|
||||
<button onClick={onClose} className="text-white/80 hover:text-white hover:bg-white/20 rounded-full p-1 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center -mt-2">
|
||||
<div className="relative mb-3">
|
||||
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center text-teal-600 text-4xl font-bold shadow-lg">
|
||||
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</div>
|
||||
<div className="absolute bottom-0 right-0 bg-teal-700 rounded-full p-1.5 shadow-md cursor-pointer hover:bg-teal-800 transition-colors">
|
||||
<Camera size={12} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl text-white font-semibold">Hi, {user?.name || 'User'}!</h3>
|
||||
<span className="mt-1 px-3 py-1 bg-white/20 text-white text-xs font-semibold rounded-full uppercase tracking-wider">
|
||||
{user?.role || 'User'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="px-6 py-4 space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<User size={18} className="text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium">Username</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{user?.username || 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<Mail size={18} className="text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 font-medium">Email</p>
|
||||
<p className="text-sm font-semibold text-gray-800 truncate">{user?.email || 'No email'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user?.role !== 'SuperAdmin' && userDepartment && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<Building2 size={18} className="text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium">Department</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{userDepartment.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Permissions Section */}
|
||||
<button
|
||||
onClick={() => setShowPermissions(!showPermissions)}
|
||||
className="w-full flex items-center justify-between p-3 bg-amber-50 hover:bg-amber-100 rounded-xl transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||
<Shield size={18} className="text-amber-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xs text-gray-500 font-medium">Your Permissions</p>
|
||||
<p className="text-sm font-semibold text-gray-800">View what you can do</p>
|
||||
</div>
|
||||
</div>
|
||||
{showPermissions ? <ChevronUp size={18} className="text-amber-600" /> : <ChevronDown size={18} className="text-amber-600" />}
|
||||
</button>
|
||||
|
||||
{showPermissions && userPermissions && (
|
||||
<div className="bg-amber-50 rounded-xl p-4 border border-amber-200">
|
||||
<h4 className="font-semibold text-amber-800 mb-2">{userPermissions.title}</h4>
|
||||
<ul className="space-y-2">
|
||||
{userPermissions.permissions.map((perm, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2 text-sm text-amber-700">
|
||||
<span className="text-amber-500 mt-0.5">•</span>
|
||||
{perm}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sign Out Button */}
|
||||
<div className="px-6 pb-4">
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors font-medium"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
setIsProfileOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Work Allocation System</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="p-2 text-gray-600 hover:bg-gray-100 rounded-full relative">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="w-10 h-10 bg-teal-600 rounded-full flex items-center justify-center text-white font-medium hover:bg-teal-700"
|
||||
>
|
||||
{user?.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfilePopup
|
||||
isOpen={isProfileOpen}
|
||||
onClose={() => setIsProfileOpen(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
84
src/components/layout/Sidebar.tsx
Normal file
84
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { LayoutDashboard, Users, Briefcase, CalendarCheck, DollarSign, ClipboardList } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const SidebarItem: React.FC<SidebarItemProps> = ({ icon: Icon, label, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center space-x-3 px-6 py-4 cursor-pointer transition-colors duration-200 outline-none focus:outline-none ${
|
||||
active
|
||||
? 'bg-blue-900 border-l-4 border-blue-400 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white border-l-4 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="font-medium text-sm tracking-wide uppercase">{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
interface SidebarProps {
|
||||
activePage: string;
|
||||
onNavigate: (page: string) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({ activePage, onNavigate }) => {
|
||||
const { user } = useAuth();
|
||||
const canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-[#1e293b] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||
<ClipboardList size={24} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-white text-lg font-bold tracking-wide">Work Allocation</h1>
|
||||
<p className="text-gray-400 text-xs">Management System</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 py-4">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Dashboard"
|
||||
active={activePage === 'dashboard'}
|
||||
onClick={() => onNavigate('dashboard')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Users}
|
||||
label="User Management"
|
||||
active={activePage === 'users'}
|
||||
onClick={() => onNavigate('users')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Briefcase}
|
||||
label="Work Allocation"
|
||||
active={activePage === 'allocation'}
|
||||
onClick={() => onNavigate('allocation')}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={CalendarCheck}
|
||||
label="Attendance"
|
||||
active={activePage === 'attendance'}
|
||||
onClick={() => onNavigate('attendance')}
|
||||
/>
|
||||
{canManageRates && (
|
||||
<SidebarItem
|
||||
icon={DollarSign}
|
||||
label="Contractor Rates"
|
||||
active={activePage === 'rates'}
|
||||
onClick={() => onNavigate('rates')}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
43
src/components/ui/Button.tsx
Normal file
43
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantStyles = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
|
||||
ghost: 'bg-transparent border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const widthStyle = fullWidth ? 'w-full' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${widthStyle} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
38
src/components/ui/Card.tsx
Normal file
38
src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-sm ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardHeader: React.FC<CardHeaderProps> = ({ title, action, className = '' }) => {
|
||||
return (
|
||||
<div className={`flex justify-between items-center p-6 border-b border-gray-200 ${className}`}>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{title}</h2>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardContent: React.FC<CardContentProps> = ({ children, className = '' }) => {
|
||||
return <div className={`p-6 ${className}`}>{children}</div>;
|
||||
};
|
||||
87
src/components/ui/Input.tsx
Normal file
87
src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { InputHTMLAttributes } from 'react';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const Input: React.FC<InputProps> = ({ label, error, required, className = '', disabled, ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectProps extends InputHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
options: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ label, error, required, options, className = '', disabled, ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-600 cursor-not-allowed' : ''} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TextAreaProps extends InputHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export const TextArea: React.FC<TextAreaProps> = ({ label, error, required, rows = 3, className = '', ...props }) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
rows={rows}
|
||||
className={`w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
src/components/ui/Table.tsx
Normal file
73
src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface TableProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Table: React.FC<TableProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`w-full ${className}`}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const TableHeader: React.FC<TableHeaderProps> = ({ children }) => {
|
||||
return (
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">{children}</tr>
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableBodyProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const TableBody: React.FC<TableBodyProps> = ({ children }) => {
|
||||
return <tbody>{children}</tbody>;
|
||||
};
|
||||
|
||||
interface TableRowProps {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TableRow: React.FC<TableRowProps> = ({ children, onClick, className = '' }) => {
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className={`border-b border-gray-100 hover:bg-gray-50 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableHeadProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TableHead: React.FC<TableHeadProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<th className={`text-left py-3 px-4 text-sm font-medium text-gray-600 ${className}`}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
interface TableCellProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TableCell: React.FC<TableCellProps> = ({ children, className = '' }) => {
|
||||
return <td className={`py-3 px-4 text-sm text-gray-700 ${className}`}>{children}</td>;
|
||||
};
|
||||
91
src/contexts/AuthContext.tsx
Normal file
91
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import type { User } from '../types';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateUser: (user: User) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session
|
||||
const token = localStorage.getItem('token');
|
||||
const storedUser = localStorage.getItem('user');
|
||||
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
setUser(parsedUser);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored user:', error);
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const response = await api.login(username, password);
|
||||
|
||||
// Store token and user
|
||||
localStorage.setItem('token', response.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.user));
|
||||
|
||||
setUser(response.user);
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateUser = (updatedUser: User) => {
|
||||
setUser(updatedUser);
|
||||
localStorage.setItem('user', JSON.stringify(updatedUser));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
updateUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
68
src/hooks/useDepartments.ts
Normal file
68
src/hooks/useDepartments.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import type { Department, SubDepartment } from '../types';
|
||||
|
||||
export const useDepartments = () => {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getDepartments();
|
||||
setDepartments(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch departments');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDepartments();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
departments,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchDepartments,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSubDepartments = (departmentId?: string) => {
|
||||
const [subDepartments, setSubDepartments] = useState<SubDepartment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchSubDepartments = async () => {
|
||||
if (!departmentId) {
|
||||
setSubDepartments([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getSubDepartments(parseInt(departmentId));
|
||||
setSubDepartments(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch subdepartments');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubDepartments();
|
||||
}, [departmentId]);
|
||||
|
||||
return {
|
||||
subDepartments,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchSubDepartments,
|
||||
};
|
||||
};
|
||||
84
src/hooks/useEmployees.ts
Normal file
84
src/hooks/useEmployees.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import type { User } from '../types';
|
||||
|
||||
export const useEmployees = (filters?: { role?: string; departmentId?: number }) => {
|
||||
const [employees, setEmployees] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchEmployees = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getUsers(filters);
|
||||
setEmployees(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch employees');
|
||||
console.error('Failed to fetch employees:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployees();
|
||||
}, [JSON.stringify(filters)]);
|
||||
|
||||
const createEmployee = async (data: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newEmployee = await api.createUser(data);
|
||||
await fetchEmployees(); // Refresh list
|
||||
return newEmployee;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create employee');
|
||||
console.error('Failed to create employee:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateEmployee = async (id: number, data: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateUser(id, data);
|
||||
await fetchEmployees(); // Refresh list
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update employee');
|
||||
console.error('Failed to update employee:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmployee = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteUser(id);
|
||||
await fetchEmployees(); // Refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete employee');
|
||||
console.error('Failed to delete employee:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
employees,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchEmployees,
|
||||
createEmployee,
|
||||
updateEmployee,
|
||||
deleteEmployee,
|
||||
};
|
||||
};
|
||||
84
src/hooks/useWorkAllocations.ts
Normal file
84
src/hooks/useWorkAllocations.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '../services/api';
|
||||
import type { WorkAllocation } from '../types';
|
||||
|
||||
export const useWorkAllocations = (filters?: { employeeId?: number; status?: string; departmentId?: number }) => {
|
||||
const [allocations, setAllocations] = useState<WorkAllocation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAllocations = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getWorkAllocations(filters);
|
||||
setAllocations(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch work allocations');
|
||||
console.error('Failed to fetch work allocations:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllocations();
|
||||
}, [JSON.stringify(filters)]);
|
||||
|
||||
const createAllocation = async (data: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newAllocation = await api.createWorkAllocation(data);
|
||||
await fetchAllocations(); // Refresh list
|
||||
return newAllocation;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create work allocation');
|
||||
console.error('Failed to create work allocation:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAllocation = async (id: number, status: string, completionDate?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkAllocationStatus(id, status, completionDate);
|
||||
await fetchAllocations(); // Refresh list
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update work allocation');
|
||||
console.error('Failed to update work allocation:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllocation = async (id: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.deleteWorkAllocation(id);
|
||||
await fetchAllocations(); // Refresh list
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete work allocation');
|
||||
console.error('Failed to delete work allocation:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
allocations,
|
||||
loading,
|
||||
error,
|
||||
refresh: fetchAllocations,
|
||||
createAllocation,
|
||||
updateAllocation,
|
||||
deleteAllocation,
|
||||
};
|
||||
};
|
||||
3
src/index.css
Normal file
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
357
src/pages/AttendancePage.tsx
Normal file
357
src/pages/AttendancePage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { AlertTriangle, CheckCircle, Clock, RefreshCw, LogIn, LogOut, Search, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Card, CardContent } from '../components/ui/Card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Select, Input } from '../components/ui/Input';
|
||||
import { api } from '../services/api';
|
||||
import { useEmployees } from '../hooks/useEmployees';
|
||||
|
||||
export const AttendancePage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'records' | 'checkin'>('records');
|
||||
const [attendance, setAttendance] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { employees } = useEmployees();
|
||||
|
||||
// Check-in form state
|
||||
const [selectedEmployee, setSelectedEmployee] = useState('');
|
||||
const [workDate, setWorkDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [checkInLoading, setCheckInLoading] = useState(false);
|
||||
const [employeeStatus, setEmployeeStatus] = useState<any>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortField, setSortField] = useState<'date' | 'employee' | 'status'>('date');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Fetch attendance records
|
||||
const fetchAttendance = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.getAttendance();
|
||||
setAttendance(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch attendance');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttendance();
|
||||
}, []);
|
||||
|
||||
// Check employee status when selected
|
||||
useEffect(() => {
|
||||
if (selectedEmployee && workDate) {
|
||||
const record = attendance.find(
|
||||
a => a.employee_id === parseInt(selectedEmployee) &&
|
||||
a.work_date?.split('T')[0] === workDate
|
||||
);
|
||||
setEmployeeStatus(record || null);
|
||||
} else {
|
||||
setEmployeeStatus(null);
|
||||
}
|
||||
}, [selectedEmployee, workDate, attendance]);
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
if (!selectedEmployee) {
|
||||
alert('Please select an employee');
|
||||
return;
|
||||
}
|
||||
setCheckInLoading(true);
|
||||
try {
|
||||
await api.checkIn(parseInt(selectedEmployee), workDate);
|
||||
await fetchAttendance();
|
||||
setEmployeeStatus({ status: 'CheckedIn' });
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to check in');
|
||||
} finally {
|
||||
setCheckInLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckOut = async () => {
|
||||
if (!selectedEmployee) {
|
||||
alert('Please select an employee');
|
||||
return;
|
||||
}
|
||||
setCheckInLoading(true);
|
||||
try {
|
||||
await api.checkOut(parseInt(selectedEmployee), workDate);
|
||||
await fetchAttendance();
|
||||
setEmployeeStatus({ status: 'CheckedOut' });
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to check out');
|
||||
} finally {
|
||||
setCheckInLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const employeeOptions = [
|
||||
{ value: '', label: 'Select Employee' },
|
||||
...employees.filter(e => e.role === 'Employee').map(e => ({
|
||||
value: String(e.id),
|
||||
label: `${e.name} (${e.username})`
|
||||
}))
|
||||
];
|
||||
|
||||
// Filter and sort attendance records
|
||||
const filteredAndSortedAttendance = useMemo(() => {
|
||||
let filtered = attendance;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(record =>
|
||||
record.employee_name?.toLowerCase().includes(query) ||
|
||||
record.status?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortField) {
|
||||
case 'date':
|
||||
comparison = new Date(a.work_date).getTime() - new Date(b.work_date).getTime();
|
||||
break;
|
||||
case 'employee':
|
||||
comparison = (a.employee_name || '').localeCompare(b.employee_name || '');
|
||||
break;
|
||||
case 'status':
|
||||
comparison = (a.status || '').localeCompare(b.status || '');
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [attendance, searchQuery, sortField, sortDirection]);
|
||||
|
||||
const handleSort = (field: 'date' | 'employee' | 'status') => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: 'date' | 'employee' | 'status' }) => {
|
||||
if (sortField !== field) return <ArrowUpDown size={14} className="ml-1 text-gray-400" />;
|
||||
return sortDirection === 'asc'
|
||||
? <ArrowUp size={14} className="ml-1 text-blue-600" />
|
||||
: <ArrowDown size={14} className="ml-1 text-blue-600" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('records')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'records'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Attendance Records
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('checkin')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'checkin'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Check In/Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
{activeTab === 'records' && (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="relative min-w-[300px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by employee name or status..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={fetchAttendance}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Total Records: {filteredAndSortedAttendance.length}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading attendance records...</div>
|
||||
) : filteredAndSortedAttendance.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() => handleSort('employee')}
|
||||
className="flex items-center hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Employee <SortIcon field="employee" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() => handleSort('date')}
|
||||
className="flex items-center hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Date <SortIcon field="date" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>Check In</TableHead>
|
||||
<TableHead>Check Out</TableHead>
|
||||
<TableHead>
|
||||
<button
|
||||
onClick={() => handleSort('status')}
|
||||
className="flex items-center hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Status <SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedAttendance.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>{record.id}</TableCell>
|
||||
<TableCell>{record.employee_name || '-'}</TableCell>
|
||||
<TableCell>{new Date(record.work_date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
{record.check_in_time
|
||||
? new Date(record.check_in_time).toLocaleTimeString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{record.check_out_time
|
||||
? new Date(record.check_out_time).toLocaleTimeString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
record.status === 'CheckedOut' ? 'bg-green-100 text-green-700' :
|
||||
record.status === 'CheckedIn' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{record.status === 'CheckedOut' ? 'Completed' :
|
||||
record.status === 'CheckedIn' ? 'Checked In' : record.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? 'No matching records found' : 'No attendance records found'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'checkin' && (
|
||||
<div className="max-w-2xl">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">Check In / Check Out Management</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">Manage employee attendance</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Select
|
||||
label="Select Employee"
|
||||
value={selectedEmployee}
|
||||
onChange={(e) => setSelectedEmployee(e.target.value)}
|
||||
options={employeeOptions}
|
||||
/>
|
||||
<Input
|
||||
label="Work Date"
|
||||
type="date"
|
||||
value={workDate}
|
||||
onChange={(e) => setWorkDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedEmployee && (
|
||||
<div className={`border rounded-md p-4 flex items-start ${
|
||||
employeeStatus?.status === 'CheckedIn'
|
||||
? 'bg-blue-50 border-blue-200'
|
||||
: employeeStatus?.status === 'CheckedOut'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}>
|
||||
{employeeStatus?.status === 'CheckedIn' ? (
|
||||
<>
|
||||
<Clock size={20} className="text-blue-600 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-800">
|
||||
Employee is currently checked in. Check-in time: {
|
||||
employeeStatus.check_in_time
|
||||
? new Date(employeeStatus.check_in_time).toLocaleTimeString()
|
||||
: 'N/A'
|
||||
}
|
||||
</p>
|
||||
</>
|
||||
) : employeeStatus?.status === 'CheckedOut' ? (
|
||||
<>
|
||||
<CheckCircle size={20} className="text-green-600 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-800">
|
||||
Employee has completed attendance for this date.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={20} className="text-yellow-600 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-yellow-800">Employee has not checked in for this date</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-4 pt-4">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleCheckIn}
|
||||
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status === 'CheckedIn' || employeeStatus?.status === 'CheckedOut'}
|
||||
>
|
||||
<LogIn size={16} className="mr-2" />
|
||||
{checkInLoading ? 'Processing...' : 'Check In'}
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={handleCheckOut}
|
||||
disabled={checkInLoading || !selectedEmployee || employeeStatus?.status !== 'CheckedIn'}
|
||||
>
|
||||
<LogOut size={16} className="mr-2" />
|
||||
{checkInLoading ? 'Processing...' : 'Check Out'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
235
src/pages/DashboardPage.tsx
Normal file
235
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Briefcase, Clock, Building2 } from 'lucide-react';
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
import { Card, CardHeader, CardContent } from '../components/ui/Card';
|
||||
import { useEmployees } from '../hooks/useEmployees';
|
||||
import { useDepartments } from '../hooks/useDepartments';
|
||||
import { useWorkAllocations } from '../hooks/useWorkAllocations';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { api } from '../services/api';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const { employees, loading: employeesLoading } = useEmployees();
|
||||
const { departments, loading: deptLoading } = useDepartments();
|
||||
const { allocations, loading: allocLoading } = useWorkAllocations();
|
||||
const { user } = useAuth();
|
||||
const [attendance, setAttendance] = useState<any[]>([]);
|
||||
|
||||
const [roleData, setRoleData] = useState<Array<{ name: string; value: number; fill: string }>>([]);
|
||||
|
||||
// Filter departments for supervisors (only show their department)
|
||||
const isSupervisor = user?.role === 'Supervisor';
|
||||
const filteredDepartments = isSupervisor
|
||||
? departments.filter(d => d.id === user?.department_id)
|
||||
: departments;
|
||||
|
||||
// Fetch today's attendance
|
||||
useEffect(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
api.getAttendance({ startDate: today, endDate: today })
|
||||
.then(setAttendance)
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Calculate role distribution
|
||||
useEffect(() => {
|
||||
if (employees.length > 0) {
|
||||
const roleCounts: Record<string, number> = {};
|
||||
employees.forEach(e => {
|
||||
roleCounts[e.role] = (roleCounts[e.role] || 0) + 1;
|
||||
});
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
'SuperAdmin': '#8b5cf6',
|
||||
'Supervisor': '#3b82f6',
|
||||
'Contractor': '#f59e0b',
|
||||
'Employee': '#10b981'
|
||||
};
|
||||
|
||||
setRoleData(
|
||||
Object.entries(roleCounts).map(([role, count]) => ({
|
||||
name: role,
|
||||
value: count,
|
||||
fill: colors[role] || '#6b7280'
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [employees]);
|
||||
|
||||
const loading = employeesLoading || deptLoading || allocLoading;
|
||||
|
||||
// Stats calculations
|
||||
const stats = {
|
||||
totalUsers: employees.length,
|
||||
totalDepartments: filteredDepartments.length,
|
||||
totalAllocations: allocations.length,
|
||||
pendingAllocations: allocations.filter(a => a.status === 'Pending').length,
|
||||
completedAllocations: allocations.filter(a => a.status === 'Completed').length,
|
||||
todayAttendance: attendance.length,
|
||||
checkedIn: attendance.filter(a => a.status === 'CheckedIn').length,
|
||||
checkedOut: attendance.filter(a => a.status === 'CheckedOut').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{loading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">TOTAL USERS</h3>
|
||||
<Users size={20} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-800">{stats.totalUsers}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Registered in system</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">DEPARTMENTS</h3>
|
||||
<Building2 size={20} className="text-purple-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-800">{stats.totalDepartments}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Active departments</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">WORK ALLOCATIONS</h3>
|
||||
<Briefcase size={20} className="text-orange-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-800">{stats.totalAllocations}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{stats.pendingAllocations} pending, {stats.completedAllocations} completed
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-600">TODAY'S ATTENDANCE</h3>
|
||||
<Clock size={20} className="text-green-600" />
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-800">{stats.todayAttendance}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{stats.checkedIn} in, {stats.checkedOut} out
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* User Distribution */}
|
||||
<Card>
|
||||
<CardHeader title="User Distribution by Role" />
|
||||
<CardContent>
|
||||
{roleData.length > 0 ? (
|
||||
<div className="flex items-center">
|
||||
<div className="w-1/2">
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={roleData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={40}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
>
|
||||
{roleData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.fill} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-2">
|
||||
{roleData.map((item) => (
|
||||
<div key={item.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: item.fill }}
|
||||
></div>
|
||||
<span className="text-sm text-gray-600">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-800">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">No user data</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Departments Overview */}
|
||||
<Card>
|
||||
<CardHeader title={isSupervisor ? 'My Department' : 'Departments'} />
|
||||
<CardContent>
|
||||
{filteredDepartments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredDepartments.map((dept, idx) => {
|
||||
const deptUsers = employees.filter(e => e.department_id === dept.id).length;
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444'];
|
||||
return (
|
||||
<div key={dept.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-3"
|
||||
style={{ backgroundColor: colors[idx % colors.length] }}
|
||||
></div>
|
||||
<span className="font-medium text-gray-800">{dept.name}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">{deptUsers} users</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">No departments</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardHeader title="Recent Work Allocations" />
|
||||
<CardContent>
|
||||
{allocations.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{allocations.slice(0, 5).map((alloc) => (
|
||||
<div key={alloc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">
|
||||
{alloc.employee_name || 'Unknown Employee'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{alloc.description || 'No description'} • {new Date(alloc.assigned_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
alloc.status === 'Completed' ? 'bg-green-100 text-green-700' :
|
||||
alloc.status === 'InProgress' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{alloc.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-400 py-8">No recent allocations</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
352
src/pages/LoginPage.tsx
Normal file
352
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import {
|
||||
Users, Lock, Eye, EyeOff, XCircle, Mail, ArrowRight,
|
||||
CheckCircle, X, Sparkles, Shield, KeyRound
|
||||
} from 'lucide-react';
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showError, setShowError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
|
||||
// Forgot password modal state
|
||||
const [showForgotModal, setShowForgotModal] = useState(false);
|
||||
const [forgotEmail, setForgotEmail] = useState('');
|
||||
const [forgotLoading, setForgotLoading] = useState(false);
|
||||
const [forgotSuccess, setForgotSuccess] = useState(false);
|
||||
const [forgotError, setForgotError] = useState('');
|
||||
|
||||
// Auto-hide error after 5 seconds
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowError(false);
|
||||
setTimeout(() => setError(''), 300);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error;
|
||||
const errorMessage = error.message?.includes('401') || error.message?.includes('Unauthorized') || error.message?.includes('Invalid')
|
||||
? 'Invalid username or password'
|
||||
: error.message || 'Login failed. Please check your credentials.';
|
||||
setError(errorMessage);
|
||||
console.error('Login error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setForgotLoading(true);
|
||||
setForgotError('');
|
||||
|
||||
// Simulate API call (replace with actual API call)
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
// In a real app, you'd call: await api.requestPasswordReset(forgotEmail);
|
||||
setForgotSuccess(true);
|
||||
} catch {
|
||||
setForgotError('Failed to send reset email. Please try again.');
|
||||
} finally {
|
||||
setForgotLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForgotModal = () => {
|
||||
setShowForgotModal(false);
|
||||
setForgotEmail('');
|
||||
setForgotSuccess(false);
|
||||
setForgotError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-pulse" />
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl animate-pulse delay-500" />
|
||||
</div>
|
||||
|
||||
{/* Floating particles */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-1 h-1 bg-white/20 rounded-full animate-float"
|
||||
style={{
|
||||
left: `${Math.random() * 100}%`,
|
||||
top: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
animationDuration: `${5 + Math.random() * 10}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
{/* Login Card */}
|
||||
<div className="bg-white/10 backdrop-blur-xl rounded-3xl shadow-2xl p-10 border border-white/20">
|
||||
{/* Logo & Title */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl shadow-lg mb-4 relative">
|
||||
<Shield size={40} className="text-white" strokeWidth={1.5} />
|
||||
<Sparkles size={16} className="text-yellow-300 absolute -top-1 -right-1 animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Welcome Back</h1>
|
||||
<p className="text-blue-200/70 text-sm">Sign in to your account to continue</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Username Input */}
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70 group-focus-within:text-blue-400 transition-colors">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="w-full pl-12 pr-4 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-blue-200/50 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 focus:bg-white/15 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Input */}
|
||||
<div className="relative group">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-blue-300/70 group-focus-within:text-blue-400 transition-colors">
|
||||
<Lock size={20} />
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full pl-12 pr-12 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-blue-200/50 focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50 focus:bg-white/15 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-blue-300/70 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="w-4 h-4 bg-white/10 border-white/30 rounded text-blue-500 focus:ring-blue-400/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="ml-2 text-blue-200/70 group-hover:text-blue-200 transition-colors">Remember me</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForgotModal(true)}
|
||||
className="text-blue-300 hover:text-blue-200 transition-colors hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Login Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username || !password}
|
||||
className="w-full bg-gradient-to-r from-blue-500 via-blue-600 to-purple-600 hover:from-blue-600 hover:via-blue-700 hover:to-purple-700 text-white font-semibold py-4 rounded-xl shadow-lg hover:shadow-blue-500/25 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-4 bg-transparent text-blue-200/50">Work Allocation System</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="text-center">
|
||||
<p className="text-blue-200/40 text-xs">
|
||||
Secure login powered by JWT authentication
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version badge */}
|
||||
<div className="text-center mt-6">
|
||||
<span className="text-blue-300/30 text-xs">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Toast */}
|
||||
{error && (
|
||||
<div className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-50 transition-all duration-300 ${showError ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'}`}>
|
||||
<div className="bg-gradient-to-r from-red-500 to-red-600 text-white px-6 py-4 rounded-2xl shadow-2xl flex items-center gap-3 min-w-[320px] border border-red-400/30">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<XCircle size={24} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">Login Failed</p>
|
||||
<p className="text-sm text-red-100">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forgot Password Modal */}
|
||||
{showForgotModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={closeForgotModal}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-slate-800/90 backdrop-blur-xl rounded-2xl shadow-2xl p-8 w-full max-w-md border border-white/10 animate-in fade-in zoom-in duration-200">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={closeForgotModal}
|
||||
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
|
||||
{!forgotSuccess ? (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-2xl shadow-lg mb-4">
|
||||
<KeyRound size={32} className="text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Forgot Password?</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Enter your email address and we'll send you instructions to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleForgotPassword} className="space-y-4">
|
||||
<div className="relative">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<Mail size={20} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
value={forgotEmail}
|
||||
onChange={(e) => setForgotEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
className="w-full pl-12 pr-4 py-4 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-400/50 focus:border-amber-400/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{forgotError && (
|
||||
<p className="text-red-400 text-sm text-center">{forgotError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={forgotLoading || !forgotEmail}
|
||||
className="w-full bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{forgotLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail size={18} />
|
||||
Send Reset Link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Back to login */}
|
||||
<button
|
||||
onClick={closeForgotModal}
|
||||
className="w-full mt-4 text-gray-400 hover:text-white text-sm transition-colors"
|
||||
>
|
||||
← Back to login
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
/* Success State */
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-full shadow-lg mb-4">
|
||||
<CheckCircle size={32} className="text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Check Your Email</h2>
|
||||
<p className="text-gray-400 text-sm mb-6">
|
||||
We've sent password reset instructions to<br />
|
||||
<span className="text-white font-medium">{forgotEmail}</span>
|
||||
</p>
|
||||
<button
|
||||
onClick={closeForgotModal}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-white font-semibold py-4 rounded-xl shadow-lg transition-all duration-300"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for floating animation */}
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.2; }
|
||||
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.5; }
|
||||
}
|
||||
.animate-float {
|
||||
animation: float linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
410
src/pages/RatesPage.tsx
Normal file
410
src/pages/RatesPage.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, RefreshCw, Trash2, Edit, DollarSign, Search } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../components/ui/Card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input, Select } from '../components/ui/Input';
|
||||
import { api } from '../services/api';
|
||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export const RatesPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'add'>('list');
|
||||
const { user } = useAuth();
|
||||
const { departments } = useDepartments();
|
||||
const [rates, setRates] = useState<any[]>([]);
|
||||
const [contractors, setContractors] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
contractorId: '',
|
||||
subDepartmentId: '',
|
||||
activity: '',
|
||||
rate: '',
|
||||
effectiveDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
const [selectedDept, setSelectedDept] = useState('');
|
||||
const { subDepartments } = useSubDepartments(selectedDept);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Edit mode
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
// Fetch rates
|
||||
const fetchRates = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await api.getContractorRates();
|
||||
setRates(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch rates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch contractors
|
||||
const fetchContractors = async () => {
|
||||
try {
|
||||
const data = await api.getUsers({ role: 'Contractor' });
|
||||
setContractors(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch contractors:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
fetchContractors();
|
||||
}, []);
|
||||
|
||||
// Auto-select department for supervisors
|
||||
useEffect(() => {
|
||||
if (user?.role === 'Supervisor' && user?.department_id) {
|
||||
setSelectedDept(String(user.department_id));
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
contractorId: '',
|
||||
subDepartmentId: '',
|
||||
activity: '',
|
||||
rate: '',
|
||||
effectiveDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setEditingId(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.contractorId || !formData.rate || !formData.effectiveDate) {
|
||||
setFormError('Contractor, rate, and effective date are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setFormLoading(true);
|
||||
setFormError('');
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.updateContractorRate(editingId, {
|
||||
rate: parseFloat(formData.rate),
|
||||
activity: formData.activity || undefined,
|
||||
effectiveDate: formData.effectiveDate,
|
||||
});
|
||||
} else {
|
||||
await api.setContractorRate({
|
||||
contractorId: parseInt(formData.contractorId),
|
||||
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : undefined,
|
||||
activity: formData.activity || undefined,
|
||||
rate: parseFloat(formData.rate),
|
||||
effectiveDate: formData.effectiveDate,
|
||||
});
|
||||
}
|
||||
|
||||
resetForm();
|
||||
setActiveTab('list');
|
||||
fetchRates();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to save rate');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (rate: any) => {
|
||||
setFormData({
|
||||
contractorId: String(rate.contractor_id),
|
||||
subDepartmentId: rate.sub_department_id ? String(rate.sub_department_id) : '',
|
||||
activity: rate.activity || '',
|
||||
rate: String(rate.rate),
|
||||
effectiveDate: rate.effective_date?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
});
|
||||
setEditingId(rate.id);
|
||||
setActiveTab('add');
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this rate?')) return;
|
||||
try {
|
||||
await api.deleteContractorRate(id);
|
||||
fetchRates();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete rate');
|
||||
}
|
||||
};
|
||||
|
||||
const canManageRates = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
||||
|
||||
// Filter rates based on search
|
||||
const filteredRates = useMemo(() => {
|
||||
if (!searchQuery) return rates;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return rates.filter(rate =>
|
||||
rate.contractor_name?.toLowerCase().includes(query) ||
|
||||
rate.sub_department_name?.toLowerCase().includes(query) ||
|
||||
rate.activity?.toLowerCase().includes(query)
|
||||
);
|
||||
}, [rates, searchQuery]);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => { setActiveTab('list'); resetForm(); }}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'list'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Rate List
|
||||
</button>
|
||||
{canManageRates && (
|
||||
<button
|
||||
onClick={() => setActiveTab('add')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'add'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{editingId ? 'Edit Rate' : 'Add Rate'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
{activeTab === 'list' && (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="relative min-w-[300px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by contractor, sub-department, activity..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={fetchRates}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Total Rates: {filteredRates.length}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading rates...</div>
|
||||
) : filteredRates.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHead>Contractor</TableHead>
|
||||
<TableHead>Sub-Department</TableHead>
|
||||
<TableHead>Activity</TableHead>
|
||||
<TableHead>Rate Type</TableHead>
|
||||
<TableHead>Rate (₹)</TableHead>
|
||||
<TableHead>Effective Date</TableHead>
|
||||
{canManageRates && <TableHead>Actions</TableHead>}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRates.map((rate) => (
|
||||
<TableRow key={rate.id}>
|
||||
<TableCell className="font-medium">{rate.contractor_name}</TableCell>
|
||||
<TableCell>{rate.sub_department_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
rate.activity === 'Loading' || rate.activity === 'Unloading'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{rate.activity || 'Standard'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-gray-500">
|
||||
{rate.activity === 'Loading' || rate.activity === 'Unloading'
|
||||
? 'Per Unit'
|
||||
: 'Flat Rate'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-green-600 font-semibold">₹{rate.rate}</span>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(rate.effective_date).toLocaleDateString()}</TableCell>
|
||||
{canManageRates && (
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(rate)}
|
||||
className="text-blue-600"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(rate.id)}
|
||||
className="text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? 'No matching rates found' : 'No rates configured yet. Add one to get started!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'add' && canManageRates && (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{editingId ? 'Edit Rate' : 'Add New Rate'}
|
||||
</h3>
|
||||
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Rate Calculation Info</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li><strong>Loading/Unloading:</strong> Total = Units × Rate per Unit</li>
|
||||
<li><strong>Standard/Other:</strong> Total = Flat Rate (no unit calculation)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-md">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Select
|
||||
label="Contractor"
|
||||
name="contractorId"
|
||||
value={formData.contractorId}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
disabled={!!editingId}
|
||||
options={[
|
||||
{ value: '', label: 'Select Contractor' },
|
||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
{user?.role === 'Supervisor' ? (
|
||||
<Input
|
||||
label="Department"
|
||||
value={departments.find(d => d.id === user?.department_id)?.name || 'Loading...'}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Department"
|
||||
value={selectedDept}
|
||||
onChange={(e) => setSelectedDept(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select Department' },
|
||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Sub-Department"
|
||||
name="subDepartmentId"
|
||||
value={formData.subDepartmentId}
|
||||
onChange={handleInputChange}
|
||||
disabled={!!editingId}
|
||||
options={[
|
||||
{ value: '', label: 'Select Sub-Department (Optional)' },
|
||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Activity Type"
|
||||
name="activity"
|
||||
value={formData.activity}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'Select Activity (Optional)' },
|
||||
{ value: 'Loading', label: 'Loading (per unit × rate)' },
|
||||
{ value: 'Unloading', label: 'Unloading (per unit × rate)' },
|
||||
{ value: 'Standard', label: 'Standard Work (flat rate)' },
|
||||
{ value: 'Other', label: 'Other (flat rate)' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label={formData.activity === 'Loading' || formData.activity === 'Unloading'
|
||||
? "Rate per Unit (₹)"
|
||||
: "Standard Rate (₹)"}
|
||||
name="rate"
|
||||
type="number"
|
||||
value={formData.rate}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter rate amount"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Effective Date"
|
||||
name="effectiveDate"
|
||||
type="date"
|
||||
value={formData.effectiveDate}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => { setActiveTab('list'); resetForm(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={formLoading}>
|
||||
{formLoading ? 'Saving...' : (
|
||||
<>
|
||||
<DollarSign size={16} className="mr-2" />
|
||||
{editingId ? 'Update Rate' : 'Add Rate'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
786
src/pages/UsersPage.tsx
Normal file
786
src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { RefreshCw, Plus, Trash2, Edit, Save, X, Search, AlertTriangle, UserX } from 'lucide-react';
|
||||
import { Card, CardHeader, CardContent } from '../components/ui/Card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input, Select } from '../components/ui/Input';
|
||||
import { useEmployees } from '../hooks/useEmployees';
|
||||
import { useDepartments } from '../hooks/useDepartments';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { api } from '../services/api';
|
||||
|
||||
export const UsersPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'list' | 'add' | 'edit' | 'delete'>('list');
|
||||
const [filterRole, setFilterRole] = useState('');
|
||||
const [filterDept, setFilterDept] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { employees, loading, error, refresh, createEmployee, deleteEmployee, updateEmployee } = useEmployees();
|
||||
const { departments } = useDepartments();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'Employee',
|
||||
departmentId: '',
|
||||
contractorId: '',
|
||||
isActive: true,
|
||||
});
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [contractors, setContractors] = useState<any[]>([]);
|
||||
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||
|
||||
// Load contractors when role is Employee
|
||||
React.useEffect(() => {
|
||||
if (formData.role === 'Employee') {
|
||||
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
|
||||
}
|
||||
}, [formData.role]);
|
||||
|
||||
// Check if current user can manage users
|
||||
const canManageUsers = currentUser?.role === 'SuperAdmin' || currentUser?.role === 'Supervisor';
|
||||
const isSupervisor = currentUser?.role === 'Supervisor';
|
||||
|
||||
// Filter departments for supervisors (only show their department)
|
||||
const filteredDepartments = isSupervisor
|
||||
? departments.filter(d => d.id === currentUser?.department_id)
|
||||
: departments;
|
||||
|
||||
const roleOptions = [
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'SuperAdmin', label: 'Super Admin' },
|
||||
{ value: 'Supervisor', label: 'Supervisor' },
|
||||
{ value: 'Contractor', label: 'Contractor' },
|
||||
{ value: 'Employee', label: 'Employee' },
|
||||
];
|
||||
|
||||
const deptOptions = isSupervisor
|
||||
? [{ value: String(currentUser?.department_id), label: filteredDepartments[0]?.name || 'My Department' }]
|
||||
: [
|
||||
{ value: '', label: 'All Departments' },
|
||||
...departments.map(d => ({ value: String(d.id), label: d.name })),
|
||||
];
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
// Validation
|
||||
if (!formData.username || !formData.password || !formData.name || !formData.email) {
|
||||
setFormError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setFormError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
setFormError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
setFormLoading(true);
|
||||
setFormError('');
|
||||
|
||||
try {
|
||||
await createEmployee({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
departmentId: formData.departmentId ? parseInt(formData.departmentId) : null,
|
||||
contractorId: formData.contractorId ? parseInt(formData.contractorId) : null,
|
||||
});
|
||||
|
||||
// Reset form and switch to list
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'Employee',
|
||||
departmentId: '',
|
||||
contractorId: '',
|
||||
});
|
||||
setActiveTab('list');
|
||||
refresh();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to create user');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (id: number, username: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user "${username}"?`)) return;
|
||||
|
||||
try {
|
||||
await deleteEmployee(id);
|
||||
refresh();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditUser = (user: any) => {
|
||||
setFormData({
|
||||
username: user.username,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
departmentId: user.department_id ? String(user.department_id) : '',
|
||||
contractorId: user.contractor_id ? String(user.contractor_id) : '',
|
||||
isActive: user.is_active,
|
||||
});
|
||||
setEditingUserId(user.id);
|
||||
setActiveTab('edit');
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleUpdateUser = async () => {
|
||||
if (!formData.name || !formData.email) {
|
||||
setFormError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
setFormLoading(true);
|
||||
setFormError('');
|
||||
|
||||
try {
|
||||
await updateEmployee(editingUserId!, {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
role: formData.role,
|
||||
departmentId: formData.departmentId ? parseInt(formData.departmentId) : null,
|
||||
contractorId: formData.contractorId ? parseInt(formData.contractorId) : null,
|
||||
isActive: formData.isActive,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
setActiveTab('list');
|
||||
refresh();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to update user');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
name: '',
|
||||
email: '',
|
||||
role: 'Employee',
|
||||
departmentId: '',
|
||||
contractorId: '',
|
||||
isActive: true,
|
||||
});
|
||||
setEditingUserId(null);
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
// Auto-set filter for supervisors
|
||||
React.useEffect(() => {
|
||||
if (isSupervisor && currentUser?.department_id) {
|
||||
setFilterDept(String(currentUser.department_id));
|
||||
}
|
||||
}, [isSupervisor, currentUser?.department_id]);
|
||||
|
||||
// Filter employees
|
||||
const filteredEmployees = employees.filter(emp => {
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
emp.name?.toLowerCase().includes(query) ||
|
||||
emp.username?.toLowerCase().includes(query) ||
|
||||
emp.email?.toLowerCase().includes(query) ||
|
||||
emp.role?.toLowerCase().includes(query);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
if (filterRole && emp.role !== filterRole) return false;
|
||||
// For supervisors, always filter by their department
|
||||
if (isSupervisor && currentUser?.department_id) {
|
||||
if (emp.department_id !== currentUser.department_id) return false;
|
||||
} else if (filterDept && emp.department_id !== parseInt(filterDept)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex space-x-8 px-6">
|
||||
<button
|
||||
onClick={() => { setActiveTab('list'); resetForm(); }}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'list'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
User List
|
||||
</button>
|
||||
{canManageUsers && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setActiveTab('add'); resetForm(); }}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'add'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Add User
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('edit')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'edit'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Edit User
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('delete')}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'delete'
|
||||
? 'border-red-500 text-red-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Delete Users
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
{activeTab === 'list' && (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="relative min-w-[300px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users by name, username, email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
options={deptOptions}
|
||||
className="w-48 flex-shrink-0"
|
||||
value={filterDept}
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
disabled={isSupervisor}
|
||||
/>
|
||||
<Select
|
||||
options={roleOptions}
|
||||
className="w-48 flex-shrink-0"
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole(e.target.value)}
|
||||
/>
|
||||
<Button variant="ghost" onClick={refresh}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Total Users: {filteredEmployees.length}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">Loading...</div>
|
||||
) : filteredEmployees.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>USERNAME</TableHead>
|
||||
<TableHead>FULL NAME</TableHead>
|
||||
<TableHead>EMAIL</TableHead>
|
||||
<TableHead>ROLE</TableHead>
|
||||
<TableHead>DEPARTMENT</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead>ACTIONS</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEmployees.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell className="text-blue-600">{user.username}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.role === 'SuperAdmin' ? 'bg-purple-100 text-purple-700' :
|
||||
user.role === 'Supervisor' ? 'bg-blue-100 text-blue-700' :
|
||||
user.role === 'Contractor' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{user.department_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canManageUsers && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.id, user.username)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : !loading && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No users found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'add' && (
|
||||
<div className="max-w-3xl">
|
||||
{formError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">User Information</h3>
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<Input
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Full Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Confirm Password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Role & Department</h3>
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<Select
|
||||
label="Role"
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
options={roleOptions.slice(1)}
|
||||
/>
|
||||
<Select
|
||||
label="Department"
|
||||
name="departmentId"
|
||||
value={formData.departmentId}
|
||||
onChange={handleInputChange}
|
||||
options={deptOptions.slice(1)}
|
||||
/>
|
||||
{formData.role === 'Employee' && (
|
||||
<Select
|
||||
label="Contractor (for Employees)"
|
||||
name="contractorId"
|
||||
value={formData.contractorId}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'Select Contractor' },
|
||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => { setActiveTab('list'); resetForm(); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleCreateUser}
|
||||
disabled={formLoading}
|
||||
>
|
||||
{formLoading ? (
|
||||
'Creating...'
|
||||
) : (
|
||||
<>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create User
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'edit' && (
|
||||
<div className="max-w-3xl">
|
||||
{formError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!editingUserId ? (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Select User to Edit</h3>
|
||||
<Select
|
||||
label="Select User"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const user = employees.find(emp => emp.id === parseInt(e.target.value));
|
||||
if (user) handleEditUser(user);
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: 'Choose a user to edit...' },
|
||||
...employees.map(emp => ({
|
||||
value: String(emp.id),
|
||||
label: `${emp.name} (${emp.username}) - ${emp.role}`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Edit User: {formData.username}</h3>
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<Input
|
||||
label="Username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
disabled
|
||||
/>
|
||||
<Input
|
||||
label="Full Name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Status"
|
||||
name="isActive"
|
||||
value={formData.isActive ? 'true' : 'false'}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, isActive: e.target.value === 'true' }))}
|
||||
options={[
|
||||
{ value: 'true', label: 'Active' },
|
||||
{ value: 'false', label: 'Inactive' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Role & Department</h3>
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
<Select
|
||||
label="Role"
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
options={roleOptions.slice(1)}
|
||||
/>
|
||||
<Select
|
||||
label="Department"
|
||||
name="departmentId"
|
||||
value={formData.departmentId}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'No Department' },
|
||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
||||
]}
|
||||
/>
|
||||
{formData.role === 'Employee' && (
|
||||
<Select
|
||||
label="Contractor (for Employees)"
|
||||
name="contractorId"
|
||||
value={formData.contractorId}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'Select Contractor' },
|
||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => resetForm()}
|
||||
>
|
||||
<X size={16} className="mr-2" />
|
||||
Clear Selection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdateUser}
|
||||
disabled={formLoading}
|
||||
>
|
||||
{formLoading ? (
|
||||
'Saving...'
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} className="mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'delete' && canManageUsers && (
|
||||
<div>
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h4 className="font-semibold text-red-800">Warning: Permanent Action</h4>
|
||||
<p className="text-sm text-red-700 mt-1">
|
||||
Deleting a user is permanent and cannot be undone. All associated data will be removed.
|
||||
{isSupervisor && " As a Supervisor, you can only delete Employees and Contractors in your department."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div className="relative min-w-[300px] flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users to delete..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
{!isSupervisor && (
|
||||
<Select
|
||||
options={deptOptions}
|
||||
className="w-48 flex-shrink-0"
|
||||
value={filterDept}
|
||||
onChange={(e) => setFilterDept(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'All Deletable Roles' },
|
||||
{ value: 'Employee', label: 'Employee' },
|
||||
{ value: 'Contractor', label: 'Contractor' },
|
||||
]}
|
||||
className="w-48 flex-shrink-0"
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
// Filter users that can be deleted
|
||||
const deletableUsers = employees.filter(emp => {
|
||||
// SuperAdmins and Supervisors cannot be deleted from this tab
|
||||
if (emp.role === 'SuperAdmin' || emp.role === 'Supervisor') return false;
|
||||
|
||||
// Only Employees and Contractors can be deleted
|
||||
if (emp.role !== 'Employee' && emp.role !== 'Contractor') return false;
|
||||
|
||||
// Supervisors can only delete users in their department
|
||||
if (isSupervisor && emp.department_id !== currentUser?.department_id) return false;
|
||||
|
||||
// Apply search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
emp.name?.toLowerCase().includes(query) ||
|
||||
emp.username?.toLowerCase().includes(query) ||
|
||||
emp.email?.toLowerCase().includes(query);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// Apply role filter
|
||||
if (filterRole && emp.role !== filterRole) return false;
|
||||
|
||||
// Apply department filter (for SuperAdmin)
|
||||
if (!isSupervisor && filterDept && emp.department_id !== parseInt(filterDept)) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 text-sm text-gray-600">
|
||||
Deletable Users: {deletableUsers.length}
|
||||
</div>
|
||||
|
||||
{deletableUsers.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>USERNAME</TableHead>
|
||||
<TableHead>FULL NAME</TableHead>
|
||||
<TableHead>EMAIL</TableHead>
|
||||
<TableHead>ROLE</TableHead>
|
||||
<TableHead>DEPARTMENT</TableHead>
|
||||
<TableHead>STATUS</TableHead>
|
||||
<TableHead>ACTION</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deletableUsers.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-red-50">
|
||||
<TableCell>{user.id}</TableCell>
|
||||
<TableCell className="text-blue-600">{user.username}</TableCell>
|
||||
<TableCell>{user.name}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.role === 'Contractor' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{user.role}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{user.department_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`Are you sure you want to permanently delete "${user.name}" (${user.username})?\n\nThis action cannot be undone!`)) {
|
||||
deleteEmployee(user.id);
|
||||
}
|
||||
}}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50 hover:border-red-400"
|
||||
>
|
||||
<UserX size={14} className="mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<UserX size={48} className="mx-auto mb-4 text-gray-300" />
|
||||
<p>No deletable users found</p>
|
||||
<p className="text-sm mt-1">
|
||||
{isSupervisor
|
||||
? "Only Employees and Contractors in your department can be deleted."
|
||||
: "Only Employees and Contractors can be deleted from this tab."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
516
src/pages/WorkAllocationPage.tsx
Normal file
516
src/pages/WorkAllocationPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, RefreshCw, CheckCircle, Trash2, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '../components/ui/Card';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '../components/ui/Table';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Input, Select, TextArea } from '../components/ui/Input';
|
||||
import { useWorkAllocations } from '../hooks/useWorkAllocations';
|
||||
import { useDepartments, useSubDepartments } from '../hooks/useDepartments';
|
||||
import { useEmployees } from '../hooks/useEmployees';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { api } from '../services/api';
|
||||
|
||||
export const WorkAllocationPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'create' | 'view' | 'summary'>('view');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const { allocations, loading, error, refresh, createAllocation, updateAllocation, deleteAllocation } = useWorkAllocations();
|
||||
const { departments } = useDepartments();
|
||||
const { employees } = useEmployees();
|
||||
const { user } = useAuth();
|
||||
const [contractors, setContractors] = useState<any[]>([]);
|
||||
|
||||
// Check if user is supervisor (limited to their department)
|
||||
const isSupervisor = user?.role === 'Supervisor';
|
||||
const canCreateAllocation = user?.role === 'SuperAdmin' || user?.role === 'Supervisor';
|
||||
|
||||
// Get supervisor's department name
|
||||
const supervisorDeptName = departments.find(d => d.id === user?.department_id)?.name || '';
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
employeeId: '',
|
||||
contractorId: '',
|
||||
subDepartmentId: '',
|
||||
activity: '',
|
||||
description: '',
|
||||
assignedDate: new Date().toISOString().split('T')[0],
|
||||
rateId: '',
|
||||
departmentId: '',
|
||||
units: '',
|
||||
});
|
||||
const [selectedDept, setSelectedDept] = useState('');
|
||||
const { subDepartments } = useSubDepartments(selectedDept);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [contractorRates, setContractorRates] = useState<any[]>([]);
|
||||
|
||||
// Fetch contractor rates when contractor changes
|
||||
useEffect(() => {
|
||||
if (formData.contractorId) {
|
||||
api.getContractorRates({ contractorId: parseInt(formData.contractorId) })
|
||||
.then(setContractorRates)
|
||||
.catch(console.error);
|
||||
} else {
|
||||
setContractorRates([]);
|
||||
}
|
||||
}, [formData.contractorId]);
|
||||
|
||||
// Get selected rate details
|
||||
const selectedRate = contractorRates.find(r => r.id === parseInt(formData.rateId));
|
||||
|
||||
// Check if rate is per unit (Loading/Unloading)
|
||||
const isPerUnitRate = selectedRate?.activity === 'Loading' || selectedRate?.activity === 'Unloading';
|
||||
|
||||
// Calculate total amount
|
||||
const unitCount = parseFloat(formData.units) || 0;
|
||||
const rateAmount = parseFloat(selectedRate?.rate) || 0;
|
||||
const totalAmount = isPerUnitRate ? unitCount * rateAmount : rateAmount;
|
||||
|
||||
// Auto-select department for supervisors when user data loads
|
||||
useEffect(() => {
|
||||
if (isSupervisor && user?.department_id) {
|
||||
const deptId = String(user.department_id);
|
||||
setSelectedDept(deptId);
|
||||
setFormData(prev => ({ ...prev, departmentId: deptId }));
|
||||
}
|
||||
}, [isSupervisor, user?.department_id]);
|
||||
|
||||
// Load contractors
|
||||
useEffect(() => {
|
||||
api.getUsers({ role: 'Contractor' }).then(setContractors).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Filter employees by selected contractor
|
||||
const filteredEmployees = formData.contractorId
|
||||
? employees.filter(e => e.contractor_id === parseInt(formData.contractorId))
|
||||
: employees.filter(e => e.role === 'Employee');
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const handleCreateAllocation = async () => {
|
||||
if (!formData.employeeId || !formData.contractorId) {
|
||||
setFormError('Please select employee and contractor');
|
||||
return;
|
||||
}
|
||||
|
||||
setFormLoading(true);
|
||||
setFormError('');
|
||||
|
||||
try {
|
||||
await createAllocation({
|
||||
employeeId: parseInt(formData.employeeId),
|
||||
contractorId: parseInt(formData.contractorId),
|
||||
subDepartmentId: formData.subDepartmentId ? parseInt(formData.subDepartmentId) : null,
|
||||
activity: formData.activity || null,
|
||||
description: formData.description,
|
||||
assignedDate: formData.assignedDate,
|
||||
rate: selectedRate?.rate || null,
|
||||
units: isPerUnitRate ? unitCount : null,
|
||||
totalAmount: totalAmount || null,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
employeeId: '',
|
||||
contractorId: '',
|
||||
subDepartmentId: '',
|
||||
activity: '',
|
||||
description: '',
|
||||
assignedDate: new Date().toISOString().split('T')[0],
|
||||
rateId: '',
|
||||
departmentId: isSupervisor ? String(user?.department_id) : '',
|
||||
units: '',
|
||||
});
|
||||
setActiveTab('view');
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Failed to create allocation');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkComplete = async (id: number) => {
|
||||
try {
|
||||
await updateAllocation(id, 'Completed', new Date().toISOString().split('T')[0]);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to update allocation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('Are you sure you want to delete this allocation?')) return;
|
||||
try {
|
||||
await deleteAllocation(id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Failed to delete allocation');
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate summary stats
|
||||
const stats = {
|
||||
total: allocations.length,
|
||||
completed: allocations.filter(a => a.status === 'Completed').length,
|
||||
inProgress: allocations.filter(a => a.status === 'InProgress').length,
|
||||
pending: allocations.filter(a => a.status === 'Pending').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Card>
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex space-x-8 px-6">
|
||||
{['create', 'view', 'summary'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab as any)}
|
||||
className={`py-4 px-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'create' && 'Create Allocation'}
|
||||
{tab === 'view' && 'View Allocations'}
|
||||
{tab === 'summary' && 'Work Summary'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
{activeTab === 'create' && (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Create New Work Allocation</h3>
|
||||
|
||||
{formError && (
|
||||
<div className="p-3 bg-red-100 text-red-700 rounded-md">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Select
|
||||
label="Contractor"
|
||||
name="contractorId"
|
||||
value={formData.contractorId}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
options={[
|
||||
{ value: '', label: 'Select Contractor' },
|
||||
...contractors.map(c => ({ value: String(c.id), label: c.name }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Employee"
|
||||
name="employeeId"
|
||||
value={formData.employeeId}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
options={[
|
||||
{ value: '', label: 'Select Employee' },
|
||||
...filteredEmployees.map(e => ({ value: String(e.id), label: e.name }))
|
||||
]}
|
||||
/>
|
||||
{isSupervisor ? (
|
||||
<Input
|
||||
label="Department"
|
||||
value={supervisorDeptName || 'Loading...'}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Department"
|
||||
value={selectedDept}
|
||||
onChange={(e) => setSelectedDept(e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: 'Select Department' },
|
||||
...departments.map(d => ({ value: String(d.id), label: d.name }))
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
label="Sub-Department"
|
||||
name="subDepartmentId"
|
||||
value={formData.subDepartmentId}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'Select Sub-Department' },
|
||||
...subDepartments.map(s => ({ value: String(s.id), label: s.name }))
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Activity"
|
||||
name="activity"
|
||||
value={formData.activity}
|
||||
onChange={handleInputChange}
|
||||
options={[
|
||||
{ value: '', label: 'Select Activity' },
|
||||
{ value: 'Loading', label: 'Loading' },
|
||||
{ value: 'Unloading', label: 'Unloading' },
|
||||
{ value: 'Standard', label: 'Standard Work' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Assigned Date"
|
||||
name="assignedDate"
|
||||
type="date"
|
||||
value={formData.assignedDate}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Rate"
|
||||
name="rateId"
|
||||
value={formData.rateId}
|
||||
onChange={handleInputChange}
|
||||
disabled={!formData.contractorId}
|
||||
options={[
|
||||
{ value: '', label: formData.contractorId ? 'Select Rate' : 'Select Contractor First' },
|
||||
...contractorRates.map(r => ({
|
||||
value: String(r.id),
|
||||
label: `₹${r.rate} - ${r.activity || 'Standard'} ${r.sub_department_name ? `(${r.sub_department_name})` : ''}`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
{isPerUnitRate && (
|
||||
<Input
|
||||
label="Number of Units"
|
||||
name="units"
|
||||
type="number"
|
||||
value={formData.units}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter units"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<div className="col-span-2">
|
||||
<TextArea
|
||||
label="Description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Work description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Calculation Box */}
|
||||
{selectedRate && (
|
||||
<div className="col-span-2 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="font-semibold text-blue-800 mb-3">Rate Calculation</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">Rate Type:</span>
|
||||
<span className="ml-2 font-medium">{isPerUnitRate ? 'Per Unit' : 'Flat Rate'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Rate:</span>
|
||||
<span className="ml-2 font-medium">₹{rateAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
{isPerUnitRate && (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-gray-600">Units:</span>
|
||||
<span className="ml-2 font-medium">{unitCount || 0}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">Calculation:</span>
|
||||
<span className="ml-2 font-medium">{unitCount} × ₹{rateAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-blue-300">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold text-blue-800">Total Amount:</span>
|
||||
<span className="text-2xl font-bold text-green-600">₹{totalAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<Button variant="outline" onClick={() => setActiveTab('view')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateAllocation} disabled={formLoading}>
|
||||
{formLoading ? 'Creating...' : (
|
||||
<>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Create Allocation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'view' && (
|
||||
<div>
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by employee, contractor, sub-department..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={refresh}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-8 text-red-600">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const filteredAllocations = allocations.filter(a => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
a.employee_name?.toLowerCase().includes(query) ||
|
||||
a.contractor_name?.toLowerCase().includes(query) ||
|
||||
a.sub_department_name?.toLowerCase().includes(query) ||
|
||||
a.activity?.toLowerCase().includes(query) ||
|
||||
a.status?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
return loading ? (
|
||||
<div className="text-center py-8">Loading work allocations...</div>
|
||||
) : filteredAllocations.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Employee</TableHead>
|
||||
<TableHead>Contractor</TableHead>
|
||||
<TableHead>Sub-Department</TableHead>
|
||||
<TableHead>Activity</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Rate Details</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAllocations.map((allocation) => {
|
||||
const isPerUnit = allocation.activity === 'Loading' || allocation.activity === 'Unloading';
|
||||
const units = parseFloat(allocation.units) || 0;
|
||||
const rate = parseFloat(allocation.rate) || 0;
|
||||
const total = parseFloat(allocation.total_amount) || (isPerUnit ? units * rate : rate);
|
||||
|
||||
return (
|
||||
<TableRow key={allocation.id}>
|
||||
<TableCell>{allocation.id}</TableCell>
|
||||
<TableCell>{allocation.employee_name || '-'}</TableCell>
|
||||
<TableCell>{allocation.contractor_name || '-'}</TableCell>
|
||||
<TableCell>{allocation.sub_department_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
{allocation.activity ? (
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
allocation.activity === 'Loading' || allocation.activity === 'Unloading'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{allocation.activity}
|
||||
</span>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>{new Date(allocation.assigned_date).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
{rate > 0 ? (
|
||||
<div className="text-sm">
|
||||
{isPerUnit && units > 0 ? (
|
||||
<div>
|
||||
<div className="text-gray-500">{units} × ₹{rate.toFixed(2)}</div>
|
||||
<div className="font-semibold text-green-600">= ₹{total.toFixed(2)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-semibold text-green-600">₹{rate.toFixed(2)}</div>
|
||||
)}
|
||||
</div>
|
||||
) : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
allocation.status === 'Completed' ? 'bg-green-100 text-green-700' :
|
||||
allocation.status === 'InProgress' ? 'bg-blue-100 text-blue-700' :
|
||||
allocation.status === 'Cancelled' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{allocation.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
{allocation.status !== 'Completed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleMarkComplete(allocation.id)}
|
||||
className="text-green-600"
|
||||
title="Mark Complete"
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(allocation.id)}
|
||||
className="text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{searchQuery ? 'No matching allocations found.' : 'No work allocations found. Create one to get started!'}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'summary' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-6">Work Summary & Statistics</h3>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{[
|
||||
{ label: 'TOTAL ALLOCATIONS', value: stats.total, color: 'bg-gray-100' },
|
||||
{ label: 'COMPLETED', value: stats.completed, color: 'bg-green-100' },
|
||||
{ label: 'IN PROGRESS', value: stats.inProgress, color: 'bg-blue-100' },
|
||||
{ label: 'PENDING', value: stats.pending, color: 'bg-yellow-100' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className={`${stat.color} border border-gray-200 rounded-lg p-6`}>
|
||||
<div className="text-xs text-gray-500 mb-2">{stat.label}</div>
|
||||
<div className="text-3xl font-bold text-gray-800">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
207
src/services/api.ts
Normal file
207
src/services/api.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api';
|
||||
|
||||
class ApiService {
|
||||
private baseURL: string;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
private getToken(): string | null {
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseURL}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
async login(username: string, password: string) {
|
||||
return this.request<{ token: string; user: any }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request<any>('/auth/me');
|
||||
}
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string) {
|
||||
return this.request<{ message: string }>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
}
|
||||
|
||||
// Users
|
||||
async getUsers(params?: { role?: string; departmentId?: number }) {
|
||||
const query = new URLSearchParams(params as any).toString();
|
||||
return this.request<any[]>(`/users${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getUser(id: number) {
|
||||
return this.request<any>(`/users/${id}`);
|
||||
}
|
||||
|
||||
async createUser(data: any) {
|
||||
return this.request<any>('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateUser(id: number, data: any) {
|
||||
return this.request<any>(`/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUser(id: number) {
|
||||
return this.request<{ message: string }>(`/users/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Departments
|
||||
async getDepartments() {
|
||||
return this.request<any[]>('/departments');
|
||||
}
|
||||
|
||||
async getDepartment(id: number) {
|
||||
return this.request<any>(`/departments/${id}`);
|
||||
}
|
||||
|
||||
async getSubDepartments(departmentId: number) {
|
||||
return this.request<any[]>(`/departments/${departmentId}/sub-departments`);
|
||||
}
|
||||
|
||||
async createDepartment(name: string) {
|
||||
return this.request<any>('/departments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
// Work Allocations
|
||||
async getWorkAllocations(params?: { employeeId?: number; status?: string; departmentId?: number }) {
|
||||
const query = new URLSearchParams(params as any).toString();
|
||||
return this.request<any[]>(`/work-allocations${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getWorkAllocation(id: number) {
|
||||
return this.request<any>(`/work-allocations/${id}`);
|
||||
}
|
||||
|
||||
async createWorkAllocation(data: any) {
|
||||
return this.request<any>('/work-allocations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkAllocationStatus(id: number, status: string, completionDate?: string) {
|
||||
return this.request<any>(`/work-allocations/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status, completionDate }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWorkAllocation(id: number) {
|
||||
return this.request<{ message: string }>(`/work-allocations/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// Attendance
|
||||
async getAttendance(params?: { employeeId?: number; startDate?: string; endDate?: string; status?: string }) {
|
||||
const query = new URLSearchParams(params as any).toString();
|
||||
return this.request<any[]>(`/attendance${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async checkIn(employeeId: number, workDate: string) {
|
||||
return this.request<any>('/attendance/check-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ employeeId, workDate }),
|
||||
});
|
||||
}
|
||||
|
||||
async checkOut(employeeId: number, workDate: string) {
|
||||
return this.request<any>('/attendance/check-out', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ employeeId, workDate }),
|
||||
});
|
||||
}
|
||||
|
||||
async getAttendanceSummary(params?: { startDate?: string; endDate?: string; departmentId?: number }) {
|
||||
const query = new URLSearchParams(params as any).toString();
|
||||
return this.request<any[]>(`/attendance/summary/stats${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
// Contractor Rates
|
||||
async getContractorRates(params?: { contractorId?: number; subDepartmentId?: number }) {
|
||||
const query = params ? new URLSearchParams(params as any).toString() : '';
|
||||
return this.request<any[]>(`/contractor-rates${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getCurrentRate(contractorId: number, subDepartmentId?: number) {
|
||||
const query = subDepartmentId ? `?subDepartmentId=${subDepartmentId}` : '';
|
||||
return this.request<any>(`/contractor-rates/contractor/${contractorId}/current${query}`);
|
||||
}
|
||||
|
||||
async setContractorRate(data: {
|
||||
contractorId: number;
|
||||
subDepartmentId?: number;
|
||||
activity?: string;
|
||||
rate: number;
|
||||
effectiveDate: string
|
||||
}) {
|
||||
return this.request<any>('/contractor-rates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateContractorRate(id: number, data: { rate?: number; activity?: string; effectiveDate?: string }) {
|
||||
return this.request<any>(`/contractor-rates/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteContractorRate(id: number) {
|
||||
return this.request<{ message: string }>(`/contractor-rates/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService(API_BASE_URL);
|
||||
58
src/types.ts
Normal file
58
src/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export interface Employee {
|
||||
id: string;
|
||||
name: string;
|
||||
dept: string;
|
||||
sub: string;
|
||||
activity: string;
|
||||
status: 'Present' | 'Absent';
|
||||
in: string;
|
||||
out: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface Contractor {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
employees: Employee[];
|
||||
}
|
||||
|
||||
export interface Supervisor {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
dept: string;
|
||||
contractors: Contractor[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
dept: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Allocation {
|
||||
id: number;
|
||||
empId: number;
|
||||
employee: string;
|
||||
contractor: string;
|
||||
activity: string;
|
||||
date: string;
|
||||
totalQty: number;
|
||||
completed: number;
|
||||
remaining: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
paid: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
name: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
fill?: string;
|
||||
}
|
||||
78
src/types/index.ts
Normal file
78
src/types/index.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: 'SuperAdmin' | 'Supervisor' | 'Contractor' | 'Employee';
|
||||
department_id?: number;
|
||||
contractor_id?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
department_name?: string;
|
||||
contractor_name?: string;
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SubDepartment {
|
||||
id: number;
|
||||
department_id: number;
|
||||
name: string;
|
||||
primary_activity: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkAllocation {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
supervisor_id: number;
|
||||
contractor_id: number;
|
||||
sub_department_id?: number;
|
||||
description?: string;
|
||||
assigned_date: string;
|
||||
status: 'Pending' | 'InProgress' | 'Completed' | 'Cancelled';
|
||||
completion_date?: string;
|
||||
rate?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
employee_name?: string;
|
||||
employee_username?: string;
|
||||
supervisor_name?: string;
|
||||
contractor_name?: string;
|
||||
sub_department_name?: string;
|
||||
department_name?: string;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
supervisor_id: number;
|
||||
check_in_time: string;
|
||||
check_out_time?: string;
|
||||
work_date: string;
|
||||
status: 'CheckedIn' | 'CheckedOut';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
employee_name?: string;
|
||||
employee_username?: string;
|
||||
supervisor_name?: string;
|
||||
department_name?: string;
|
||||
contractor_name?: string;
|
||||
}
|
||||
|
||||
export interface ContractorRate {
|
||||
id: number;
|
||||
contractor_id: number;
|
||||
rate: number;
|
||||
effective_date: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
contractor_name?: string;
|
||||
contractor_username?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user