1542 lines
68 KiB
TypeScript
1542 lines
68 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useState, useMemo, useRef } from 'react';
|
|
import {
|
|
Users, Plus, Search, Trash2, Edit, Eye, Loader2, X, EyeOff, CheckCircle2,
|
|
AlertTriangle, UserCheck, ChevronDown, Settings,
|
|
Filter, FileSpreadsheet, Download, RefreshCw, CheckSquare, Square,
|
|
Mail, Phone
|
|
} from 'lucide-react';
|
|
import { userService, UserResponse, UserCreate, UserUpdate } from '@/services/api/userService';
|
|
import { rolePermissionService } from '@/services/api/rolePermissionService';
|
|
import { format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
|
|
import { StatsSparklineCard, datesToSparkline, weekOverWeekChange } from '@/components/ui/StatsSparklineCard';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
import { PhoneInput } from '@/components/ui/PhoneInput';
|
|
import { validatePhone, validateEmail } from '@/lib/validation';
|
|
|
|
// Master data lookup mapped to seeded backend database IDs
|
|
const ROLES = [
|
|
{ id: '01KXRF5VQ1GQ58RDRX45BY9X1D', name: 'Super Admin' },
|
|
{ id: '01KXRF5VQ2X83NF2950KTMZT45', name: 'Admin' },
|
|
{ id: '01KXRF5VQ2X83NF2950KTMZT46', name: 'Technician' },
|
|
{ id: '01KXRF5VQ3QPM1CJZ679R152CQ', name: 'Sales' },
|
|
{ id: '01KXRF5VQ4Z7YXKKX83VSR532Q', name: 'Customer Support' },
|
|
];
|
|
|
|
const DEPARTMENTS = [
|
|
{ id: '01KXRF5VQ5NB1GN907TGZZ3C9Q', name: 'Administration' },
|
|
{ id: '01KXRF5VQ6D0NJC1RT6422QT0Y', name: 'Technical Repair' },
|
|
{ id: '01KXRF5VQ6D0NJC1RT6422QT0Z', name: 'Sales & Billing' },
|
|
{ id: '01KXRF5VQ74CRWS1951WETKC38', name: 'Support Desk' },
|
|
];
|
|
|
|
const DESIGNATIONS = [
|
|
{ id: '01KXRF5VQ8GXRR9JEMNW6VS215', name: 'General Administrator' },
|
|
{ id: '01KXRF5VQ92Q2DA0VED445M6PJ', name: 'Senior Technician' },
|
|
{ id: '01KXRF5VQ92Q2DA0VED445M6PK', name: 'Junior Technician' },
|
|
{ id: '01KXRF5VQ92Q2DA0VED445M6PM', name: 'Billing Representative' },
|
|
];
|
|
|
|
const GENDERS = ['Male', 'Female', 'Other', 'Prefer not to say'];
|
|
|
|
// Highlight match function
|
|
const highlightMatch = (text: string, query: string) => {
|
|
if (!query) return text;
|
|
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
|
return (
|
|
<>
|
|
{parts.map((part, index) =>
|
|
part.toLowerCase() === query.toLowerCase() ? (
|
|
<mark key={index} className="bg-yellow-100 text-yellow-900 rounded-sm px-0.5 font-semibold">{part}</mark>
|
|
) : (
|
|
part
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default function UsersPage() {
|
|
const [users, setUsers] = useState<UserResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Search and Sort
|
|
const [search, setSearch] = useState('');
|
|
const [sortOption, setSortOption] = useState('recently_added'); // ascending, descending, recently_added
|
|
|
|
|
|
// Filter States
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [filterRole, setFilterRole] = useState<string>('');
|
|
const [filterDept, setFilterDept] = useState<string>('');
|
|
const [filterStatus, setFilterStatus] = useState<string>('all'); // all, active, inactive
|
|
const [startDate, setStartDate] = useState<string>('');
|
|
const [endDate, setEndDate] = useState<string>('');
|
|
const [viewMode, setViewMode] = useState<'table' | 'grid'>('table');
|
|
const [draftRole, setDraftRole] = useState('');
|
|
const [draftDept, setDraftDept] = useState('');
|
|
const [draftStatus, setDraftStatus] = useState('all');
|
|
const [draftStartDate, setDraftStartDate] = useState('');
|
|
const [draftEndDate, setDraftEndDate] = useState('');
|
|
const filterRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Row Selection
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [mockSubscriptions, setMockSubscriptions] = useState([
|
|
{ id: 'SUB-RAZOR-9082', name: 'Premium ERP Support Plan' },
|
|
{ id: 'SUB-RAZOR-1249', name: 'Standard Technical SLA Plan' },
|
|
{ id: 'SUB-RAZOR-8821', name: 'Basic Helpdesk SLA Plan' }
|
|
]);
|
|
const [selectedSubId, setSelectedSubId] = useState('SUB-RAZOR-9082');
|
|
|
|
// Column Visibility
|
|
const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>({
|
|
index: true,
|
|
employee: true,
|
|
roleDept: true,
|
|
emailPhone: true,
|
|
designation: true,
|
|
status: true,
|
|
verified: true,
|
|
createdAt: true,
|
|
actions: true,
|
|
});
|
|
|
|
// Modals state
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
const [showViewModal, setShowViewModal] = useState(false);
|
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
|
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
|
const [isSoftDelete, setIsSoftDelete] = useState(true);
|
|
|
|
// Selected user for View or Edit
|
|
const [selectedUser, setSelectedUser] = useState<UserResponse | null>(null);
|
|
|
|
// Form states
|
|
const [addForm, setAddForm] = useState<UserCreate>({
|
|
first_name: '',
|
|
last_name: '',
|
|
display_name: '',
|
|
email: '',
|
|
phone: '',
|
|
password: '',
|
|
gender: 'Prefer not to say',
|
|
dob: '',
|
|
role_id: ROLES[1].id,
|
|
department_id: DEPARTMENTS[0].id,
|
|
designation_id: DESIGNATIONS[0].id,
|
|
manager_id: '',
|
|
});
|
|
|
|
const [editForm, setEditForm] = useState<UserUpdate & { password?: string }>({
|
|
first_name: '',
|
|
last_name: '',
|
|
display_name: '',
|
|
email: '',
|
|
phone: '',
|
|
password: '',
|
|
gender: 'Prefer not to say',
|
|
dob: '',
|
|
role_id: '',
|
|
department_id: '',
|
|
designation_id: '',
|
|
manager_id: '',
|
|
});
|
|
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
|
|
const [availableRoles, setAvailableRoles] = useState<{ id: string; name: string }[]>(ROLES);
|
|
|
|
const fetchUsers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await userService.getAll(0, 100);
|
|
setUsers(data);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to fetch users';
|
|
toast.error(message);
|
|
}
|
|
|
|
try {
|
|
const dynRoles = await rolePermissionService.getRoles();
|
|
if (dynRoles && dynRoles.length > 0) {
|
|
setAvailableRoles(dynRoles.map(r => ({ id: r.role_id, name: r.role_name })));
|
|
}
|
|
} catch {}
|
|
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchUsers();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!showFilters) return;
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
if (!filterRef.current?.contains(event.target as Node)) {
|
|
setShowFilters(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
}, [showFilters]);
|
|
|
|
const getRoleName = (id: string) => availableRoles.find(r => r.id === id || r.name.toLowerCase() === id.toLowerCase())?.name || id || 'Unknown';
|
|
const getDeptName = (id: string) => DEPARTMENTS.find(d => d.id === id)?.name || 'Unknown';
|
|
const getDesigName = (id: string) => DESIGNATIONS.find(d => d.id === id)?.name || 'Unknown';
|
|
|
|
// Validation Form Checks
|
|
const validateForm = (data: Partial<UserCreate>, isEdit = false): boolean => {
|
|
const errors: Record<string, string> = {};
|
|
|
|
if (!data.first_name || data.first_name.trim().length < 2) {
|
|
errors.first_name = 'First name must be at least 2 characters';
|
|
}
|
|
const emailErr = validateEmail(data.email || '', true);
|
|
if (emailErr) errors.email = emailErr;
|
|
|
|
const phoneErr = validatePhone(data.phone || '', true);
|
|
if (phoneErr) errors.phone = phoneErr;
|
|
|
|
if (!isEdit) {
|
|
const password = data.password || '';
|
|
if (password.length < 6) {
|
|
errors.password = 'Password must be at least 6 characters';
|
|
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) {
|
|
errors.password = 'Password must contain uppercase, lowercase, and a number';
|
|
}
|
|
}
|
|
|
|
if (!data.role_id) errors.role_id = 'Role is required';
|
|
if (!data.department_id) errors.department_id = 'Department is required';
|
|
if (!data.designation_id) errors.designation_id = 'Designation is required';
|
|
|
|
setFormErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
// Submit Handlers
|
|
const handleAddSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!validateForm(addForm)) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const payload = { ...addForm };
|
|
if (!payload.display_name) payload.display_name = `${payload.first_name}`;
|
|
payload.last_name = payload.display_name; // Mapped to backend User Name requirements
|
|
if (!payload.dob) delete payload.dob;
|
|
if (!payload.manager_id) delete payload.manager_id;
|
|
|
|
await userService.create(payload);
|
|
toast.success(payload.display_name ? `User "${payload.display_name}" created` : 'User created');
|
|
setShowAddModal(false);
|
|
|
|
// Reset form
|
|
setAddForm({
|
|
first_name: '',
|
|
last_name: '',
|
|
display_name: '',
|
|
email: '',
|
|
phone: '',
|
|
password: '',
|
|
gender: 'Prefer not to say',
|
|
dob: '',
|
|
role_id: ROLES[1].id,
|
|
department_id: DEPARTMENTS[0].id,
|
|
designation_id: DESIGNATIONS[0].id,
|
|
manager_id: '',
|
|
});
|
|
fetchUsers();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to create user';
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleEditClick = (user: UserResponse) => {
|
|
setSelectedUser(user);
|
|
setEditForm({
|
|
first_name: user.first_name,
|
|
last_name: user.last_name,
|
|
display_name: user.display_name || '',
|
|
email: user.email,
|
|
phone: user.phone,
|
|
password: '',
|
|
gender: user.gender || 'Prefer not to say',
|
|
dob: user.dob || '',
|
|
role_id: user.role_id,
|
|
department_id: user.department_id,
|
|
designation_id: user.designation_id,
|
|
manager_id: user.manager_id || '',
|
|
});
|
|
setFormErrors({});
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedUser) return;
|
|
if (!validateForm(editForm, true)) return;
|
|
|
|
try {
|
|
setIsSubmitting(true);
|
|
const payload: UserUpdate = {
|
|
first_name: editForm.first_name,
|
|
last_name: editForm.first_name, // Sync display/last name requirements
|
|
display_name: editForm.display_name || undefined,
|
|
email: editForm.email,
|
|
phone: editForm.phone,
|
|
gender: editForm.gender,
|
|
dob: editForm.dob || undefined,
|
|
role_id: editForm.role_id,
|
|
department_id: editForm.department_id,
|
|
designation_id: editForm.designation_id,
|
|
manager_id: editForm.manager_id || undefined,
|
|
};
|
|
|
|
if (editForm.password && editForm.password.trim().length > 0) {
|
|
payload.password = editForm.password;
|
|
}
|
|
|
|
await userService.update(selectedUser.user_id, payload);
|
|
toast.success('User details saved');
|
|
setShowEditModal(false);
|
|
fetchUsers();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update user';
|
|
toast.error(message);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleViewClick = (user: UserResponse) => {
|
|
setSelectedUser(user);
|
|
setShowViewModal(true);
|
|
};
|
|
|
|
const handleDelete = async (userId: string) => {
|
|
try {
|
|
await userService.delete(userId);
|
|
toast.success('User moved to deleted');
|
|
setDeleteId(null);
|
|
fetchUsers();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to delete user';
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
// Bulk deletion
|
|
const handleBulkDelete = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
try {
|
|
setLoading(true);
|
|
await Promise.all(selectedIds.map(id => userService.delete(id)));
|
|
toast.success(`Deleted ${selectedIds.length} ${selectedIds.length === 1 ? 'user' : 'users'}`);
|
|
setSelectedIds([]);
|
|
setShowBulkDeleteModal(false);
|
|
fetchUsers();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to delete selected users';
|
|
toast.error(message);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Bulk assign mock subscription
|
|
const handleBulkAssignSubscription = () => {
|
|
if (selectedIds.length === 0) return;
|
|
const subName = mockSubscriptions.find(s => s.id === selectedSubId)?.name;
|
|
toast.success(`Assigned ${selectedIds.length} ${selectedIds.length === 1 ? 'user' : 'users'} to ${subName || 'subscription'}`);
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
// Bulk remove mock subscription
|
|
const handleBulkRemoveSubscription = () => {
|
|
if (selectedIds.length === 0) return;
|
|
const subName = mockSubscriptions.find(s => s.id === selectedSubId)?.name || 'subscription';
|
|
toast.success(`Removed ${subName} from ${selectedIds.length} ${selectedIds.length === 1 ? 'user' : 'users'}`);
|
|
setSelectedIds([]);
|
|
};
|
|
|
|
// Toggle selection
|
|
const handleSelectRow = (id: string) => {
|
|
setSelectedIds(prev =>
|
|
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
|
);
|
|
};
|
|
|
|
const handleSelectAll = (filteredList: UserResponse[]) => {
|
|
const filteredUserIds = filteredList.map(u => u.user_id);
|
|
const allSelected = filteredUserIds.every(id => selectedIds.includes(id));
|
|
if (allSelected) {
|
|
setSelectedIds(prev => prev.filter(id => !filteredUserIds.includes(id)));
|
|
} else {
|
|
setSelectedIds(prev => Array.from(new Set([...prev, ...filteredUserIds])));
|
|
}
|
|
};
|
|
|
|
// Client-side Export Methods
|
|
const handleExportCSV = (selectedOnly = false) => {
|
|
const listToExport = selectedOnly
|
|
? users.filter(u => selectedIds.includes(u.user_id))
|
|
: users;
|
|
|
|
if (listToExport.length === 0) {
|
|
toast.warning(selectedOnly ? 'No selected users to export' : 'No users to export');
|
|
return;
|
|
}
|
|
|
|
const headers = ['S.No', 'Employee Code', 'First Name', 'Last Name', 'Email', 'Phone', 'Role', 'Department', 'Designation', 'Status', 'Verified', 'Created At'];
|
|
const rows = listToExport.map((u, i) => [
|
|
i + 1,
|
|
u.employee_code || '—',
|
|
u.first_name,
|
|
u.last_name,
|
|
u.email,
|
|
u.phone,
|
|
getRoleName(u.role_id),
|
|
getDeptName(u.department_id),
|
|
getDesigName(u.designation_id),
|
|
u.is_active ? 'Active' : 'Inactive',
|
|
`Email:${u.email_verified ? 'Yes' : 'No'} Phone:${u.phone_verified ? 'Yes' : 'No'}`,
|
|
format(new Date(u.created_at), 'yyyy-MM-dd')
|
|
]);
|
|
|
|
const csvContent = [headers.join(','), ...rows.map(e => e.map(val => `"${val}"`).join(','))].join('\n');
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `ifixkart_users_${selectedOnly ? 'selected_' : 'all_'}${format(new Date(), 'yyyyMMdd')}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
toast.success(`Downloaded ${listToExport.length} ${listToExport.length === 1 ? 'user' : 'users'} as CSV`);
|
|
};
|
|
|
|
const handleExportPDF = (selectedOnly = false) => {
|
|
const listToExport = selectedOnly
|
|
? users.filter(u => selectedIds.includes(u.user_id))
|
|
: users;
|
|
|
|
if (listToExport.length === 0) {
|
|
toast.warning(selectedOnly ? 'No selected users to export' : 'No users to export');
|
|
return;
|
|
}
|
|
|
|
const printWindow = window.open('', '_blank');
|
|
if (!printWindow) {
|
|
toast.error('Allow pop-ups to open the users print preview');
|
|
return;
|
|
}
|
|
|
|
const html = `
|
|
<html>
|
|
<head>
|
|
<title>iFixKart Staff Directory PDF Export</title>
|
|
<style>
|
|
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; color: #1e293b; }
|
|
h2 { color: #0f172a; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 11px; }
|
|
th { background-color: #0f172a; color: white; padding: 10px; text-align: left; }
|
|
td { padding: 10px; border-bottom: 1px solid #e2e8f0; }
|
|
tr:nth-child(even) { background-color: #f8fafc; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h2>iFixKart Staff Directory (${listToExport.length} Records)</h2>
|
|
<p>Exported on: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}</p>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>S.No</th>
|
|
<th>Employee Code</th>
|
|
<th>Name</th>
|
|
<th>Email</th>
|
|
<th>Phone</th>
|
|
<th>Role & Dept</th>
|
|
<th>Designation</th>
|
|
<th>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${listToExport.map((u, i) => `
|
|
<tr>
|
|
<td>${i + 1}</td>
|
|
<td><b>${u.employee_code || '—'}</b></td>
|
|
<td>${u.first_name} ${u.last_name}</td>
|
|
<td>${u.email}</td>
|
|
<td>${u.phone}</td>
|
|
<td>${getRoleName(u.role_id)} (${getDeptName(u.department_id)})</td>
|
|
<td>${getDesigName(u.designation_id)}</td>
|
|
<td>${u.is_active ? 'Active' : 'Inactive'}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
<script>
|
|
window.onload = function() { window.print(); }
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`;
|
|
printWindow.document.write(html);
|
|
printWindow.document.close();
|
|
toast.success(`Print preview opened for ${listToExport.length} ${listToExport.length === 1 ? 'user' : 'users'}`);
|
|
};
|
|
|
|
// Filter & Search Logic
|
|
const filteredUsers = useMemo(() => {
|
|
return users.filter((u) => {
|
|
// Search
|
|
const q = search.toLowerCase();
|
|
const searchMatch = !search || (
|
|
u.first_name.toLowerCase().includes(q) ||
|
|
u.last_name.toLowerCase().includes(q) ||
|
|
`${u.first_name} ${u.last_name}`.toLowerCase().includes(q) ||
|
|
(u.display_name || '').toLowerCase().includes(q) ||
|
|
u.email.toLowerCase().includes(q) ||
|
|
u.phone.toLowerCase().includes(q) ||
|
|
(u.employee_code || '').toLowerCase().includes(q) ||
|
|
getRoleName(u.role_id).toLowerCase().includes(q) ||
|
|
getDeptName(u.department_id).toLowerCase().includes(q)
|
|
);
|
|
|
|
|
|
// Dropdown/Accordion Filters
|
|
const roleMatch = !filterRole || u.role_id === filterRole;
|
|
const deptMatch = !filterDept || u.department_id === filterDept;
|
|
|
|
const statusMatch = filterStatus === 'all' ||
|
|
(filterStatus === 'active' && u.is_active) ||
|
|
(filterStatus === 'inactive' && !u.is_active);
|
|
|
|
// Date Range Match
|
|
let dateMatch = true;
|
|
if (startDate || endDate) {
|
|
const createDate = new Date(u.created_at);
|
|
if (startDate) {
|
|
dateMatch = dateMatch && createDate >= new Date(startDate);
|
|
}
|
|
if (endDate) {
|
|
dateMatch = dateMatch && createDate <= new Date(endDate);
|
|
}
|
|
}
|
|
|
|
return searchMatch && roleMatch && deptMatch && statusMatch && dateMatch;
|
|
}).sort((a, b) => {
|
|
if (sortOption === 'ascending') {
|
|
return a.first_name.localeCompare(b.first_name);
|
|
}
|
|
if (sortOption === 'descending') {
|
|
return b.first_name.localeCompare(a.first_name);
|
|
}
|
|
// Default: recently added
|
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
});
|
|
}, [users, search, sortOption, filterRole, filterDept, filterStatus, startDate, endDate]);
|
|
|
|
const pager = useClientPagination(filteredUsers);
|
|
|
|
// Status Metrics
|
|
const totalCount = users.length;
|
|
const activeCount = users.filter(u => u.is_active).length;
|
|
const inactiveCount = users.filter(u => !u.is_active).length;
|
|
const filtersActive = Boolean(filterRole || filterDept || filterStatus !== 'all' || startDate || endDate);
|
|
|
|
const allUserDates = useMemo(() => users.map((u) => u.created_at), [users]);
|
|
const activeUserDates = useMemo(
|
|
() => users.filter((u) => u.is_active).map((u) => u.created_at),
|
|
[users]
|
|
);
|
|
const inactiveUserDates = useMemo(
|
|
() => users.filter((u) => !u.is_active).map((u) => u.created_at),
|
|
[users]
|
|
);
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
|
const inputClass =
|
|
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50';
|
|
|
|
const renderStatusBadge = (active: boolean) => (
|
|
<span
|
|
className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${
|
|
active ? 'bg-success text-white' : 'bg-destructive text-white'
|
|
}`}
|
|
>
|
|
{active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Manage Users</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{totalCount}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<div className="relative group">
|
|
<button type="button" className={secondaryButton}>
|
|
<Download className="w-3.5 h-3.5" />
|
|
Export
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-52 crm-radius-section border border-border bg-card shadow-[0_8px_24px_rgba(16,24,40,0.12)] hidden group-hover:block p-1.5 overflow-hidden">
|
|
<div className="px-3 py-2 text-[11px] text-muted-foreground font-semibold">All records</div>
|
|
<button type="button" onClick={() => handleExportCSV(false)} className="w-full text-left px-3 py-2 crm-radius-section text-[13px] text-[#6b7280] hover:bg-[#eef0f4] hover:text-[#3d4654] cursor-pointer flex items-center gap-1.5">
|
|
<FileSpreadsheet className="w-4 h-4" /> Excel (.csv)
|
|
</button>
|
|
<button type="button" onClick={() => handleExportPDF(false)} className="w-full text-left px-3 py-2 crm-radius-section text-[13px] text-[#6b7280] hover:bg-[#eef0f4] hover:text-[#3d4654] cursor-pointer flex items-center gap-1.5">
|
|
<FileSpreadsheet className="w-4 h-4" /> Printable PDF
|
|
</button>
|
|
{selectedIds.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-2 text-[11px] text-muted-foreground font-semibold">Selected ({selectedIds.length})</div>
|
|
<button type="button" onClick={() => handleExportCSV(true)} className="w-full text-left px-3 py-2 crm-radius-section text-[13px] text-[#6b7280] hover:bg-[#eef0f4] hover:text-[#3d4654] cursor-pointer flex items-center gap-1.5">
|
|
<FileSpreadsheet className="w-4 h-4" /> Selected CSV
|
|
</button>
|
|
<button type="button" onClick={() => handleExportPDF(true)} className="w-full text-left px-3 py-2 crm-radius-section text-[13px] text-[#6b7280] hover:bg-[#eef0f4] hover:text-[#3d4654] cursor-pointer flex items-center gap-1.5">
|
|
<FileSpreadsheet className="w-4 h-4" /> Selected PDF
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={fetchUsers}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFormErrors({});
|
|
setShowAddModal(true);
|
|
}}
|
|
className={primaryButton}
|
|
>
|
|
<span className="w-[18px] h-[18px] rounded-full bg-white text-primary flex items-center justify-center">
|
|
<Plus className="w-3 h-3" strokeWidth={3} />
|
|
</span>
|
|
Add user
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatsSparklineCard
|
|
title="Total users"
|
|
value={totalCount}
|
|
icon={Users}
|
|
tone="green"
|
|
series={datesToSparkline(allUserDates)}
|
|
deltaPercent={weekOverWeekChange(allUserDates)}
|
|
active={filterStatus === 'all'}
|
|
onClick={() => setFilterStatus('all')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Active users"
|
|
value={activeCount}
|
|
icon={UserCheck}
|
|
tone="violet"
|
|
series={datesToSparkline(activeUserDates)}
|
|
deltaPercent={weekOverWeekChange(activeUserDates)}
|
|
active={filterStatus === 'active'}
|
|
onClick={() => setFilterStatus('active')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Inactive users"
|
|
value={inactiveCount}
|
|
icon={AlertTriangle}
|
|
tone="orange"
|
|
series={datesToSparkline(inactiveUserDates)}
|
|
deltaPercent={weekOverWeekChange(inactiveUserDates)}
|
|
active={filterStatus === 'inactive'}
|
|
onClick={() => setFilterStatus('inactive')}
|
|
/>
|
|
</div>
|
|
|
|
{selectedIds.length > 0 && (
|
|
<div className={`${dataCardShell} p-3.5 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3`}>
|
|
<div className="flex items-center gap-2 text-[13px] text-foreground font-semibold">
|
|
<CheckCircle2 className="w-4 h-4 shrink-0 text-primary" />
|
|
<span>{selectedIds.length} selected</span>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
|
<CustomSelect
|
|
value={selectedSubId}
|
|
onChange={setSelectedSubId}
|
|
aria-label="Subscription plan"
|
|
className="min-w-[220px]"
|
|
options={mockSubscriptions.map((sub) => ({ value: sub.id, label: sub.name }))}
|
|
/>
|
|
<button type="button" onClick={handleBulkAssignSubscription} className={primaryButton}>
|
|
Assign
|
|
</button>
|
|
<button type="button" onClick={handleBulkRemoveSubscription} className={secondaryButton}>
|
|
Remove license
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowBulkDeleteModal(true)}
|
|
className="inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
Delete selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className={dataCardShell}>
|
|
<div className="p-4 space-y-3">
|
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2.5 min-w-0">
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 min-w-0">
|
|
<div className="relative" ref={filterRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftRole(filterRole);
|
|
setDraftDept(filterDept);
|
|
setDraftStatus(filterStatus);
|
|
setDraftStartDate(startDate);
|
|
setDraftEndDate(endDate);
|
|
setShowFilters((open) => !open);
|
|
}}
|
|
className={`inline-flex items-center gap-1.5 h-9 px-3 crm-radius-control border bg-card text-[13px] font-medium cursor-pointer transition-colors ${
|
|
filtersActive || showFilters
|
|
? 'border-primary text-primary bg-primary/5'
|
|
: 'border-border text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
<Filter className="w-3.5 h-3.5" />
|
|
Filter
|
|
{filtersActive && <span className="w-1.5 h-1.5 rounded-full bg-primary" />}
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
{showFilters && (
|
|
<div className="absolute left-0 top-full mt-1.5 z-30 w-[260px] crm-radius-section border border-border bg-card shadow-lg p-3 space-y-3">
|
|
<div>
|
|
<label className={labelClass}>Role</label>
|
|
<CustomSelect
|
|
value={draftRole}
|
|
onChange={setDraftRole}
|
|
aria-label="Role"
|
|
options={[
|
|
{ value: '', label: 'All Roles' },
|
|
...availableRoles.map((r) => ({ value: r.id, label: r.name })),
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Department</label>
|
|
<CustomSelect
|
|
value={draftDept}
|
|
onChange={setDraftDept}
|
|
aria-label="Department"
|
|
options={[
|
|
{ value: '', label: 'All Departments' },
|
|
...DEPARTMENTS.map((d) => ({ value: d.id, label: d.name })),
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Status</label>
|
|
<CustomSelect
|
|
value={draftStatus}
|
|
onChange={setDraftStatus}
|
|
aria-label="Status"
|
|
options={[
|
|
{ value: 'all', label: 'All Statuses' },
|
|
{ value: 'active', label: 'Active only' },
|
|
{ value: 'inactive', label: 'Inactive only' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Created</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="date"
|
|
value={draftStartDate}
|
|
onChange={(e) => setDraftStartDate(e.target.value)}
|
|
className="w-1/2 h-9 px-2 crm-radius-control border border-border bg-card text-[12px] text-foreground"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={draftEndDate}
|
|
onChange={(e) => setDraftEndDate(e.target.value)}
|
|
className="w-1/2 h-9 px-2 crm-radius-control border border-border bg-card text-[12px] text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftRole('');
|
|
setDraftDept('');
|
|
setDraftStatus('all');
|
|
setDraftStartDate('');
|
|
setDraftEndDate('');
|
|
setFilterRole('');
|
|
setFilterDept('');
|
|
setFilterStatus('all');
|
|
setStartDate('');
|
|
setEndDate('');
|
|
setShowFilters(false);
|
|
}}
|
|
className={secondaryButton}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFilterRole(draftRole);
|
|
setFilterDept(draftDept);
|
|
setFilterStatus(draftStatus);
|
|
setStartDate(draftStartDate);
|
|
setEndDate(draftEndDate);
|
|
setShowFilters(false);
|
|
}}
|
|
className={primaryButton}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative w-full sm:w-[240px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search users"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
|
<CustomSelect
|
|
value={sortOption}
|
|
onChange={setSortOption}
|
|
aria-label="Sort users"
|
|
className="w-[160px]"
|
|
options={[
|
|
{ value: 'ascending', label: 'Sort A-Z' },
|
|
{ value: 'descending', label: 'Sort Z-A' },
|
|
{ value: 'recently_added', label: 'Recently added' },
|
|
]}
|
|
/>
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
|
<div className="relative group">
|
|
<button type="button" className={secondaryButton}>
|
|
<Settings className="w-3.5 h-3.5" />
|
|
Columns
|
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<div className="absolute right-0 top-full mt-1.5 w-48 crm-radius-section border border-border bg-card shadow-lg hidden group-hover:block z-20 p-2 space-y-1">
|
|
{Object.keys(visibleColumns).map(colKey => (
|
|
<label key={colKey} className="flex items-center gap-2 px-2 py-1 hover:bg-muted crm-radius-control text-[13px] text-foreground cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={visibleColumns[colKey]}
|
|
onChange={() => setVisibleColumns(prev => ({ ...prev, [colKey]: !prev[colKey] }))}
|
|
className="rounded-sm border-border text-primary focus:ring-primary w-3.5 h-3.5"
|
|
/>
|
|
<span className="capitalize">{colKey.replace(/([A-Z])/g, ' $1')}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === 'grid' ? (
|
|
loading ? (
|
|
<div className="py-12 flex justify-center border-t border-border">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : filteredUsers.length === 0 ? (
|
|
<div className="py-12 text-center text-[13px] text-muted-foreground border-t border-border">
|
|
{search ? 'No users match your search query' : 'No users created yet'}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 p-4 border-t border-border">
|
|
{pager.items.map((user) => (
|
|
<div key={user.user_id} className="crm-radius-section border border-border bg-card p-3.5 min-w-0">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div className="w-9 h-9 crm-radius-icon flex items-center justify-center text-[12px] font-bold text-primary shrink-0 bg-primary/10">
|
|
{user.first_name.charAt(0).toUpperCase()}{user.last_name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">
|
|
{user.first_name} {user.last_name}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground truncate">{getRoleName(user.role_id)}</p>
|
|
</div>
|
|
</div>
|
|
{renderStatusBadge(user.is_active)}
|
|
</div>
|
|
<div className="mt-3 space-y-1.5 text-[12px] text-muted-foreground">
|
|
<p className="flex items-center gap-1.5 truncate"><Mail className="w-3.5 h-3.5 shrink-0" />{user.email}</p>
|
|
<p className="flex items-center gap-1.5 truncate"><Phone className="w-3.5 h-3.5 shrink-0" />{user.phone || '—'}</p>
|
|
<p className="truncate">{getDeptName(user.department_id)} · {getDesigName(user.designation_id)}</p>
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-end gap-1">
|
|
<button type="button" onClick={() => handleViewClick(user)} className="w-8 h-8 crm-radius-control hover:bg-muted cursor-pointer flex items-center justify-center" title="View details">
|
|
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<button type="button" onClick={() => handleEditClick(user)} className="w-8 h-8 crm-radius-control hover:bg-muted cursor-pointer flex items-center justify-center" title="Edit user">
|
|
<Edit className="w-3.5 h-3.5 text-muted-foreground" />
|
|
</button>
|
|
<button type="button" onClick={() => setDeleteId(user.user_id)} className="w-8 h-8 crm-radius-control hover:bg-destructive/10 cursor-pointer flex items-center justify-center" title="Delete user">
|
|
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table min-w-[980px]">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-5 py-3 w-10 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectAll(filteredUsers)}
|
|
className="text-muted-foreground hover:text-foreground cursor-pointer inline-flex items-center"
|
|
>
|
|
{filteredUsers.length > 0 && filteredUsers.every(u => selectedIds.includes(u.user_id)) ? (
|
|
<CheckSquare className="w-4 h-4 text-primary" />
|
|
) : (
|
|
<Square className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</th>
|
|
{visibleColumns.index && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">S.No</th>}
|
|
{visibleColumns.employee && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Employee</th>}
|
|
{visibleColumns.roleDept && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Role & dept</th>}
|
|
{visibleColumns.emailPhone && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Email & phone</th>}
|
|
{visibleColumns.designation && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Designation</th>}
|
|
{visibleColumns.status && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Status</th>}
|
|
{visibleColumns.verified && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Verified</th>}
|
|
{visibleColumns.createdAt && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Created</th>}
|
|
{visibleColumns.actions && <th className="px-5 py-3 text-[13px] font-semibold text-gray-700 text-right">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={10} className="text-center py-12">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary mx-auto" />
|
|
<p className="text-[13px] text-muted-foreground mt-2">Loading users...</p>
|
|
</td>
|
|
</tr>
|
|
) : filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={10} className="text-center py-12">
|
|
<Users className="w-8 h-8 text-muted-foreground/30 mx-auto" />
|
|
<p className="text-[13px] text-muted-foreground mt-2">
|
|
{search ? 'No users match your search query' : 'No users created yet'}
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((user, index) => {
|
|
const isSelected = selectedIds.includes(user.user_id);
|
|
return (
|
|
<tr
|
|
key={user.user_id}
|
|
className={`border-b border-border hover:bg-muted/10 transition-colors ${ isSelected ? 'bg-primary/5' : '' }`}
|
|
>
|
|
<td className="px-5 py-3.5 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectRow(user.user_id)}
|
|
className="text-muted-foreground hover:text-foreground cursor-pointer inline-flex items-center"
|
|
>
|
|
{isSelected ? (
|
|
<CheckSquare className="w-4 h-4 text-primary" />
|
|
) : (
|
|
<Square className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</td>
|
|
{visibleColumns.index && (
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{(pager.page - 1) * pager.pageSize + index + 1}
|
|
</td>
|
|
)}
|
|
{visibleColumns.employee && (
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 crm-radius-icon flex items-center justify-center text-xs font-bold text-primary shrink-0 bg-primary/10">
|
|
{user.first_name.charAt(0).toUpperCase()}{user.last_name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="text-[13px] font-semibold text-foreground">
|
|
{highlightMatch(`${user.first_name} ${user.last_name}`, search)}
|
|
</p>
|
|
<p className="text-[11px] text-muted-foreground font-mono">{user.employee_code || '—'}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
)}
|
|
{visibleColumns.roleDept && (
|
|
<td className="px-5 py-3.5">
|
|
<span className="text-[13px] font-semibold text-foreground block">
|
|
{getRoleName(user.role_id)}
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground block">
|
|
{getDeptName(user.department_id)}
|
|
</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.emailPhone && (
|
|
<td className="px-5 py-3.5">
|
|
<span className="text-[13px] text-foreground block font-medium">
|
|
{highlightMatch(user.email, search)}
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground block font-mono">
|
|
{highlightMatch(user.phone, search)}
|
|
</span>
|
|
</td>
|
|
)}
|
|
{visibleColumns.designation && (
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{getDesigName(user.designation_id)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.status && (
|
|
<td className="px-5 py-3.5">
|
|
{renderStatusBadge(user.is_active)}
|
|
</td>
|
|
)}
|
|
{visibleColumns.verified && (
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex flex-col gap-1">
|
|
<span className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold w-max ${ user.email_verified ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground' }`}>
|
|
Email: {user.email_verified ? 'Verified' : 'Pending'}
|
|
</span>
|
|
<span className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold w-max ${ user.phone_verified ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground' }`}>
|
|
Phone: {user.phone_verified ? 'Verified' : 'Pending'}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
)}
|
|
{visibleColumns.createdAt && (
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{format(new Date(user.created_at), 'yyyy-MM-dd')}
|
|
</td>
|
|
)}
|
|
{visibleColumns.actions && (
|
|
<td className="px-5 py-3.5 text-right">
|
|
<div className="flex gap-1 justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewClick(user)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center hover:bg-muted cursor-pointer transition-colors"
|
|
title="View details"
|
|
>
|
|
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleEditClick(user)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center hover:bg-muted cursor-pointer transition-colors"
|
|
title="Edit user"
|
|
>
|
|
<Edit className="w-4 h-4 text-muted-foreground" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteId(user.user_id)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center hover:bg-destructive/10 cursor-pointer transition-colors"
|
|
title="Delete user"
|
|
>
|
|
<Trash2 className="w-4 h-4 text-destructive" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
<SlideOver
|
|
open={showAddModal}
|
|
onClose={() => !isSubmitting && setShowAddModal(false)}
|
|
title="Add user"
|
|
icon={<Users className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleAddSubmit} className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>First name <span className="text-primary">*</span></label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={addForm.first_name}
|
|
onChange={(e) => setAddForm({ ...addForm, first_name: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
{formErrors.first_name && <p className="text-[11px] text-destructive mt-1">{formErrors.first_name}</p>}
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>User name <span className="text-primary">*</span></label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="e.g. jdoe"
|
|
value={addForm.display_name}
|
|
onChange={(e) => setAddForm({ ...addForm, display_name: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Email <span className="text-primary">*</span></label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={addForm.email}
|
|
onChange={(e) => setAddForm({ ...addForm, email: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
{formErrors.email && <p className="text-[11px] text-destructive mt-1">{formErrors.email}</p>}
|
|
</div>
|
|
<div>
|
|
<PhoneInput
|
|
label="Phone"
|
|
required
|
|
value={addForm.phone}
|
|
onChange={(val) => setAddForm({ ...addForm, phone: val })}
|
|
error={formErrors.phone}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Password <span className="text-primary">*</span></label>
|
|
<input
|
|
type="password"
|
|
value={addForm.password}
|
|
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
{formErrors.password && <p className="text-[11px] text-destructive mt-1">{formErrors.password}</p>}
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Role <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={addForm.role_id}
|
|
onChange={(role_id) => setAddForm({ ...addForm, role_id })}
|
|
aria-label="Role"
|
|
options={availableRoles.map((r) => ({ value: r.id, label: r.name }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Department <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={addForm.department_id}
|
|
onChange={(department_id) => setAddForm({ ...addForm, department_id })}
|
|
aria-label="Department"
|
|
options={DEPARTMENTS.map((d) => ({ value: d.id, label: d.name }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Designation <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={addForm.designation_id}
|
|
onChange={(designation_id) => setAddForm({ ...addForm, designation_id })}
|
|
aria-label="Designation"
|
|
options={DESIGNATIONS.map((d) => ({ value: d.id, label: d.name }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAddModal(false)}
|
|
disabled={isSubmitting}
|
|
className={secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Creating...' : 'Create user'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
|
|
<SlideOver
|
|
open={showEditModal && !!selectedUser}
|
|
onClose={() => !isSubmitting && setShowEditModal(false)}
|
|
title="Edit user"
|
|
icon={<Edit className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
<form onSubmit={handleEditSubmit} className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>First name <span className="text-primary">*</span></label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={editForm.first_name}
|
|
onChange={(e) => setEditForm({ ...editForm, first_name: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
{formErrors.first_name && <p className="text-[11px] text-destructive mt-1">{formErrors.first_name}</p>}
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>User name</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.display_name}
|
|
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Email <span className="text-primary">*</span></label>
|
|
<input
|
|
type="email"
|
|
required
|
|
value={editForm.email}
|
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
{formErrors.email && <p className="text-[11px] text-destructive mt-1">{formErrors.email}</p>}
|
|
</div>
|
|
<div>
|
|
<PhoneInput
|
|
label="Phone"
|
|
required
|
|
value={editForm.phone}
|
|
onChange={(val) => setEditForm({ ...editForm, phone: val })}
|
|
error={formErrors.phone}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Reset password</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPassword ? 'text' : 'password'}
|
|
placeholder="Leave blank to keep current"
|
|
value={editForm.password}
|
|
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
|
|
className={`${inputClass} pr-10`}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer text-muted-foreground hover:text-foreground"
|
|
>
|
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
{formErrors.password && <p className="text-[11px] text-destructive mt-1">{formErrors.password}</p>}
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Date of birth</label>
|
|
<input
|
|
type="date"
|
|
value={editForm.dob}
|
|
onChange={(e) => setEditForm({ ...editForm, dob: e.target.value })}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className={labelClass}>Role <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={editForm.role_id || ''}
|
|
onChange={(role_id) => setEditForm({ ...editForm, role_id })}
|
|
aria-label="Role"
|
|
options={availableRoles.map((r) => ({ value: r.id, label: r.name }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Department <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={editForm.department_id || ''}
|
|
onChange={(department_id) => setEditForm({ ...editForm, department_id })}
|
|
aria-label="Department"
|
|
options={DEPARTMENTS.map((d) => ({ value: d.id, label: d.name }))}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Designation <span className="text-primary">*</span></label>
|
|
<CustomSelect
|
|
value={editForm.designation_id || ''}
|
|
onChange={(designation_id) => setEditForm({ ...editForm, designation_id })}
|
|
aria-label="Designation"
|
|
options={DESIGNATIONS.map((d) => ({ value: d.id, label: d.name }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowEditModal(false)}
|
|
disabled={isSubmitting}
|
|
className={secondaryButton}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button type="submit" disabled={isSubmitting} className={primaryButton}>
|
|
{isSubmitting ? 'Saving...' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</SlideOver>
|
|
|
|
<SlideOver
|
|
open={showViewModal && !!selectedUser}
|
|
onClose={() => setShowViewModal(false)}
|
|
title="User details"
|
|
icon={<Users className="w-4 h-4 text-primary shrink-0" />}
|
|
>
|
|
{selectedUser && (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-12 h-12 crm-radius-icon flex items-center justify-center text-[15px] font-bold text-primary bg-primary/10 shrink-0">
|
|
{selectedUser.first_name.charAt(0).toUpperCase()}{selectedUser.last_name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h4 className="text-[16px] font-semibold text-foreground">
|
|
{selectedUser.first_name} {selectedUser.last_name}
|
|
</h4>
|
|
{selectedUser.display_name && (
|
|
<p className="text-[13px] text-muted-foreground">Alias: {selectedUser.display_name}</p>
|
|
)}
|
|
<p className="text-[12px] text-muted-foreground font-mono mt-0.5">
|
|
Code: {selectedUser.employee_code || '—'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className={labelClass}>Role</p>
|
|
<p className="text-[13px] font-semibold text-foreground">{getRoleName(selectedUser.role_id)}</p>
|
|
</div>
|
|
<div>
|
|
<p className={labelClass}>Department</p>
|
|
<p className="text-[13px] font-semibold text-foreground">{getDeptName(selectedUser.department_id)}</p>
|
|
</div>
|
|
<div>
|
|
<p className={labelClass}>Designation</p>
|
|
<p className="text-[13px] font-semibold text-foreground">{getDesigName(selectedUser.designation_id)}</p>
|
|
</div>
|
|
<div>
|
|
<p className={labelClass}>Status</p>
|
|
{renderStatusBadge(selectedUser.is_active)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 crm-radius-section border border-border bg-muted/30 space-y-3">
|
|
<div>
|
|
<p className={labelClass}>Email</p>
|
|
<p className="text-[13px] text-foreground">{selectedUser.email}</p>
|
|
</div>
|
|
<div>
|
|
<p className={labelClass}>Phone</p>
|
|
<p className="text-[13px] text-foreground">{selectedUser.phone}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="shrink-0 border-t border-border px-5 py-3 flex justify-end bg-card">
|
|
<button type="button" onClick={() => setShowViewModal(false)} className={secondaryButton}>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SlideOver>
|
|
|
|
{deleteId && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => setDeleteId(null)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="delete-user-title"
|
|
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 id="delete-user-title" className="text-[15px] font-semibold text-foreground">
|
|
Delete user
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">
|
|
This will soft-delete the staff profile and disable system access.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close delete dialog"
|
|
onClick={() => setDeleteId(null)}
|
|
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
|
<button type="button" onClick={() => setDeleteId(null)} className={secondaryButton}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDelete(deleteId)}
|
|
className="inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showBulkDeleteModal && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => setShowBulkDeleteModal(false)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="bulk-delete-title"
|
|
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 id="bulk-delete-title" className="text-[15px] font-semibold text-foreground">
|
|
Delete selected users
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">
|
|
Delete <span className="font-semibold text-foreground">{selectedIds.length}</span> selected staff profiles?
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close bulk delete dialog"
|
|
onClick={() => setShowBulkDeleteModal(false)}
|
|
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-5 pb-2">
|
|
<label className="flex items-center justify-between gap-3 p-3 crm-radius-section border border-border bg-muted/30 cursor-pointer">
|
|
<div>
|
|
<p className="text-[13px] font-semibold text-foreground">Soft delete</p>
|
|
<p className="text-[11px] text-muted-foreground">Keep the profile archived in the database</p>
|
|
</div>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSoftDelete}
|
|
onChange={() => setIsSoftDelete(!isSoftDelete)}
|
|
className="rounded-sm border-border text-primary focus:ring-primary w-4 h-4 cursor-pointer"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
|
<button type="button" onClick={() => setShowBulkDeleteModal(false)} className={secondaryButton}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleBulkDelete}
|
|
className="inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|