Compare commits
2 Commits
4673bb36a3
...
fa0359e45c
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0359e45c | |||
| f52eabefa8 |
14
public/mockApi/master-data/admins.json
Normal file
14
public/mockApi/master-data/admins.json
Normal file
@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "id": "EMP001", "name": "Alice Williams", "designationName": "Software Engineer", "deptName": "IT", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": true },
|
||||
{ "id": "EMP002", "name": "Bob Smith", "designationName": "Senior Software Engineer", "deptName": "IT", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": false },
|
||||
{ "id": "EMP003", "name": "Charlie Brown", "designationName": "Trainer", "deptName": "Training", "branchName": "Pune", "companyName": "CLRI", "status": "Disabled", "isAdmin": false },
|
||||
{ "id": "EMP004", "name": "Diana Prince", "designationName": "Data Analyst", "deptName": "Analytics", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": true },
|
||||
{ "id": "EMP005", "name": "Evan Wright", "designationName": "QA Tester", "deptName": "Software Dev", "branchName": "Mumbai", "companyName": "TechNova Solutions", "status": "Active", "isAdmin": false },
|
||||
{ "id": "EMP006", "name": "Fiona Gallagher", "designationName": "HR Executive", "deptName": "HR", "branchName": "Chennai", "companyName": "Infotech Ltd", "status": "Active", "isAdmin": false },
|
||||
{ "id": "EMP007", "name": "George Costanza", "designationName": "Placement Officer", "deptName": "Placement", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": true },
|
||||
{ "id": "EMP008", "name": "Hannah Abbott", "designationName": "Support Engineer", "deptName": "Support", "branchName": "Ahmedabad", "companyName": "Global Systems", "status": "Disabled", "isAdmin": false },
|
||||
{ "id": "EMP009", "name": "Ian Malcolm", "designationName": "Lead Trainer", "deptName": "Training", "branchName": "Pune", "companyName": "CLRI", "status": "Active", "isAdmin": false },
|
||||
{ "id": "EMP010", "name": "Jane Porter", "designationName": "Project Manager", "deptName": "Software Dev", "branchName": "Mumbai", "companyName": "TechNova Solutions", "status": "Active", "isAdmin": true },
|
||||
{ "id": "EMP011", "name": "Kevin Hart", "designationName": "Software Engineer", "deptName": "IT", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": false },
|
||||
{ "id": "EMP012", "name": "Laura Croft", "designationName": "Data Analyst", "deptName": "Analytics", "branchName": "Bengaluru", "companyName": "CLRI", "status": "Active", "isAdmin": false }
|
||||
]
|
||||
@ -9,9 +9,9 @@ import LeaveRouter from '../../features/leave/routes/LeaveRouter';
|
||||
import ApprovalRouter from '../../features/leave/routes/ApprovalRouter';
|
||||
import AttendanceSummaryRouter from '../../features/attendance/routes/AttendanceSummaryRouter';
|
||||
import LeaveSummaryRouter from '../../features/leave/routes/LeaveSummaryRouter';
|
||||
import ManageAdminsRouter from '../../features/dashboard/routes/ManageAdminsRouter';
|
||||
import PolicyRouter from '../../features/dashboard/routes/PolicyRouter';
|
||||
import MasterDataRouter from '../../features/employee-management/routes/MasterDataRouter'; // New Import
|
||||
import ManageAdminsRouter from '../../features/employee-management/routes/ManageAdminsRouter';
|
||||
|
||||
export default function MainLayout() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
15
src/features/employee-management/api/useAdminData.ts
Normal file
15
src/features/employee-management/api/useAdminData.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchMockData } from '../../../api/client';
|
||||
import type { Employee } from './useEmployeeData';
|
||||
|
||||
// Extends Employee to include admin status
|
||||
export interface AdminEmployee extends Employee {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const useAdminEmployees = () => {
|
||||
return useQuery<AdminEmployee[]>({
|
||||
queryKey: ['adminEmployees'],
|
||||
queryFn: () => fetchMockData<AdminEmployee[]>('master-data/admins.json'),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,206 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown, Search, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Trash2 } from 'lucide-react';
|
||||
import type { AdminEmployee } from '../../api/useAdminData';
|
||||
|
||||
interface AdminTableProps {
|
||||
records: AdminEmployee[];
|
||||
onToggleAdmin: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
type SortKey = 'id' | 'name' | 'designationName' | 'deptName' | 'branchName' | 'companyName' | 'status' | 'isAdmin';
|
||||
type SortDirection = 'ascending' | 'descending';
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Active': return 'bg-present-100 text-present-700';
|
||||
case 'Disabled': return 'bg-absent-100 text-absent-700';
|
||||
default: return 'bg-app-muted text-text-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
export default function AdminTable({ records, onToggleAdmin, onDelete }: AdminTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: SortDirection }>({
|
||||
key: 'name', direction: 'ascending'
|
||||
});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchQuery) return records;
|
||||
return records.filter(record =>
|
||||
record.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.designationName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.deptName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.branchName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.companyName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
record.status.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [records, searchQuery]);
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
const sortableData = [...filteredData];
|
||||
sortableData.sort((a, b) => {
|
||||
// Handle boolean sorting for isAdmin
|
||||
if (sortConfig.key === 'isAdmin') {
|
||||
const valA = a.isAdmin ? 1 : 0;
|
||||
const valB = b.isAdmin ? 1 : 0;
|
||||
return sortConfig.direction === 'ascending' ? valA - valB : valB - valA;
|
||||
}
|
||||
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) return sortConfig.direction === 'ascending' ? -1 : 1;
|
||||
if (a[sortConfig.key] > b[sortConfig.key]) return sortConfig.direction === 'ascending' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
return sortableData;
|
||||
}, [filteredData, sortConfig]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||
const isPaginationEnabled = sortedData.length > pageSize;
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
return sortedData.slice(startIndex, startIndex + pageSize);
|
||||
}, [sortedData, currentPage, pageSize]);
|
||||
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages = [];
|
||||
const maxVisiblePages = 5;
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||||
let endPage = startPage + maxVisiblePages - 1;
|
||||
if (endPage > totalPages) {
|
||||
endPage = totalPages;
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
for (let i = startPage; i <= endPage; i++) pages.push(i);
|
||||
return pages;
|
||||
}, [currentPage, totalPages]);
|
||||
|
||||
const requestSort = (key: SortKey) => {
|
||||
let direction: SortDirection = 'ascending';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'ascending') direction = 'descending';
|
||||
setSortConfig({ key, direction });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortKey) => {
|
||||
if (sortConfig.key !== key) return <ArrowUpDown size={14} className="ml-1 text-text-light" />;
|
||||
if (sortConfig.direction === 'ascending') return <ArrowUp size={14} className="ml-1 text-primary" />;
|
||||
return <ArrowDown size={14} className="ml-1 text-primary" />;
|
||||
};
|
||||
|
||||
const formatHeader = (key: string) => {
|
||||
if (key === 'id') return 'Emp ID';
|
||||
if (key === 'name') return 'Employee Name';
|
||||
if (key === 'designationName') return 'Designation';
|
||||
if (key === 'deptName') return 'Department';
|
||||
if (key === 'branchName') return 'Branch';
|
||||
if (key === 'companyName') return 'Company';
|
||||
if (key === 'isAdmin') return 'Admin Role';
|
||||
return key.charAt(0).toUpperCase() + key.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-app-card p-6 rounded-lg shadow-sm">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
|
||||
<h2 className="text-lg font-semibold text-text-primary">Employees & Admins List</h2>
|
||||
<div className="relative w-full md:w-64">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-light" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search id, name, role..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }}
|
||||
className="pl-9 pr-3 py-2 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-app-border">
|
||||
<thead>
|
||||
<tr>
|
||||
{(['id', 'name', 'designationName', 'deptName', 'branchName', 'companyName', 'status', 'isAdmin'] as SortKey[]).map((key) => (
|
||||
<th key={key} onClick={() => requestSort(key)} className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider cursor-pointer hover:bg-app-muted transition-colors">
|
||||
<div className="flex items-center">
|
||||
{formatHeader(key)} {getSortIcon(key)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="px-6 py-3 text-left text-xs font-semibold text-text-muted uppercase tracking-wider">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-app-border">
|
||||
{paginatedData.length > 0 ? (
|
||||
paginatedData.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-app-muted transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-primary">{record.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.designationName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.deptName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.branchName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">{record.companyName}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(record.status)}`}>{record.status}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{/* Admin Toggle Switch */}
|
||||
<button
|
||||
onClick={() => onToggleAdmin(record.id)}
|
||||
className={`relative inline-flex items-center h-6 w-11 rounded-full transition-colors ${record.isAdmin ? 'bg-primary' : 'bg-app-border'}`}
|
||||
title={record.isAdmin ? "Revoke Admin" : "Make Admin"}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${record.isAdmin ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button onClick={() => onDelete(record.id)} className="text-absent-500 hover:text-absent-700 flex items-center">
|
||||
<Trash2 size={16} className="mr-1" /> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-6 py-8 text-center text-sm text-text-muted">No employees found.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mt-4 pt-4 border-t border-app-border gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-text-muted">Show</span>
|
||||
<select value={pageSize} onChange={handlePageSizeChange} className="px-2 py-1 border border-app-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary text-text-secondary bg-app-card">
|
||||
<option value={10}>10</option>
|
||||
<option value={15}>15</option>
|
||||
<option value={20}>20</option>
|
||||
</select>
|
||||
<span className="text-sm text-text-muted">entries</span>
|
||||
</div>
|
||||
|
||||
{isPaginationEnabled && totalPages > 1 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-sm text-text-muted mr-2 hidden sm:inline">Page {currentPage} of {totalPages}</span>
|
||||
<button onClick={() => setCurrentPage(1)} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsLeft size={16} /></button>
|
||||
<button onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronLeft size={16} /></button>
|
||||
{pageNumbers.map(num => (
|
||||
<button key={num} onClick={() => setCurrentPage(num)} className={`w-8 h-8 flex items-center justify-center border rounded-lg text-sm transition-colors ${currentPage === num ? 'bg-primary text-white border-primary' : 'border-app-border text-text-secondary hover:bg-app-muted'}`}>{num}</button>
|
||||
))}
|
||||
<button onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronRight size={16} /></button>
|
||||
<button onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} className="p-2 border border-app-border rounded-lg text-text-secondary hover:bg-app-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"><ChevronsRight size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/features/employee-management/pages/ManageAdmins.tsx
Normal file
118
src/features/employee-management/pages/ManageAdmins.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ShieldUser, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import Loader from '../../../components/ui/Loader';
|
||||
import ErrorState from '../../../components/ui/ErrorState';
|
||||
import ConfirmationModal from '../../../components/ui/ConfirmationModal';
|
||||
import AdminTable from '../components/manage-admins/AdminTable';
|
||||
import EmployeeFormModal from '../components/employee/EmployeeFormModal';
|
||||
import { useAdminEmployees, type AdminEmployee } from '../api/useAdminData';
|
||||
import { useCompanies } from '../api/useCompanyData';
|
||||
import { useBranches } from '../api/useBranchData';
|
||||
import { useDepartments } from '../api/useDepartmentData';
|
||||
import { useDesignations } from '../api/useDesignationData';
|
||||
|
||||
export default function ManageAdmins() {
|
||||
const { data: apiRecords, isLoading: empLoading, isError: empError } = useAdminEmployees();
|
||||
const { data: companies, isLoading: compLoading, isError: compError } = useCompanies();
|
||||
const { data: branches, isLoading: branchLoading, isError: branchError } = useBranches();
|
||||
const { data: departments, isLoading: depLoading, isError: depError } = useDepartments();
|
||||
const { data: designations, isLoading: dsgLoading, isError: dsgError } = useDesignations();
|
||||
|
||||
const [localRecords, setLocalRecords] = useState<AdminEmployee[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
useMemo(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-render
|
||||
if (apiRecords) setLocalRecords(apiRecords);
|
||||
}, [apiRecords]);
|
||||
|
||||
const handleToggleAdmin = (id: string) => {
|
||||
setLocalRecords(prev => prev.map(e => e.id === id ? { ...e, isAdmin: !e.isAdmin } : e));
|
||||
const emp = localRecords.find(e => e.id === id);
|
||||
if (emp) {
|
||||
toast.success(`${emp.name} is ${emp.isAdmin ? 'no longer an admin' : 'now an admin'}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string) => {
|
||||
setDeleteId(id);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
setLocalRecords(prev => prev.filter(e => e.id !== deleteId));
|
||||
toast.success("Employee deleted successfully.");
|
||||
setDeleteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (data: Omit<AdminEmployee, 'id' | 'isAdmin'>) => {
|
||||
const newId = `EMP${String(localRecords.length + 1).padStart(3, '0')}`;
|
||||
// New employees are not admins by default
|
||||
setLocalRecords(prev => [...prev, { id: newId, isAdmin: false, ...data }]);
|
||||
toast.success("Employee added successfully.");
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const isLoading = empLoading || compLoading || branchLoading || depLoading || dsgLoading;
|
||||
const isError = empError || compError || branchError || depError || dsgError;
|
||||
|
||||
if (isLoading) return <Loader message="Loading admins data..." />;
|
||||
if (isError) return <ErrorState message="Failed to load admins data." />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-app-card p-6 rounded-lg shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-3 bg-primary/10 rounded-full">
|
||||
<ShieldUser className="text-primary" size={28} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">Manage Admins</h2>
|
||||
<p className="text-sm text-text-muted">Toggle admin privileges or remove employees.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddClick}
|
||||
className="flex items-center justify-center px-4 py-2 bg-action text-white rounded-lg hover:bg-action-hover transition-colors font-medium"
|
||||
>
|
||||
<Plus size={18} className="mr-2" />
|
||||
Add Employee
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminTable
|
||||
records={localRecords}
|
||||
onToggleAdmin={handleToggleAdmin}
|
||||
onDelete={handleDeleteClick}
|
||||
/>
|
||||
|
||||
{isModalOpen && (
|
||||
<EmployeeFormModal
|
||||
employee={null}
|
||||
companies={companies || []}
|
||||
branches={branches || []}
|
||||
departments={departments || []}
|
||||
designations={designations || []}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteId && (
|
||||
<ConfirmationModal
|
||||
title="Delete Employee?"
|
||||
message="Are you sure you want to delete this employee? This action cannot be undone."
|
||||
onConfirm={confirmDelete}
|
||||
onClose={() => setDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,16 +1,12 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../../store/store';
|
||||
import ManageAdmins from '../../../features/employee-management/pages/ManageAdmins';
|
||||
|
||||
export default function ManageAdminsRouter() {
|
||||
const role = useSelector((state: RootState) => state.role.currentRole);
|
||||
|
||||
if (role === 'superadmin') {
|
||||
return (
|
||||
<div className="bg-app-card p-6 rounded-lg shadow">
|
||||
<h2 className="text-2xl font-bold text-primary">Manage Admins</h2>
|
||||
<p className="mt-2 text-text-muted">Create and manage branch administrators and their permissions.</p>
|
||||
</div>
|
||||
);
|
||||
return <ManageAdmins />;
|
||||
}
|
||||
|
||||
return <div className="bg-app-card p-6 rounded-lg shadow text-absent-500">Access Denied: SuperAdmins only.</div>;
|
||||
Loading…
x
Reference in New Issue
Block a user