448 lines
20 KiB
TypeScript
448 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
Search,
|
|
Filter,
|
|
Loader2,
|
|
Mail,
|
|
Phone,
|
|
MapPin,
|
|
Plus,
|
|
RefreshCw,
|
|
Download,
|
|
FileSpreadsheet,
|
|
ChevronDown,
|
|
MoreVertical,
|
|
ExternalLink,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { userService, UserResponse } from '@/services/api/userService';
|
|
import { ViewModeToggle } from '@/components/ui/ViewModeToggle';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
|
|
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' },
|
|
];
|
|
|
|
export default function StaffDirectoryPage() {
|
|
const router = useRouter();
|
|
const [users, setUsers] = useState<UserResponse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [roleFilter, setRoleFilter] = useState('');
|
|
const [sortBy, setSortBy] = useState('name');
|
|
const [viewMode, setViewMode] = useState<'table' | 'grid'>('grid');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [draftRole, setDraftRole] = useState('');
|
|
const [draftSort, setDraftSort] = useState('name');
|
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
|
const filterRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchUsers = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await userService.getAll(0, 100);
|
|
setUsers(data);
|
|
} catch {
|
|
toast.error('Failed to load staff directory');
|
|
} finally {
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!openMenuId) return;
|
|
const onPointerDown = () => setOpenMenuId(null);
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
}, [openMenuId]);
|
|
|
|
const getRoleName = (roleId: string) => ROLES.find((r) => r.id === roleId)?.name || 'Staff Member';
|
|
const getDeptName = (deptId: string) => DEPARTMENTS.find((d) => d.id === deptId)?.name || 'Operations';
|
|
const getDesigName = (desigId: string) => DESIGNATIONS.find((d) => d.id === desigId)?.name || '';
|
|
|
|
const sortedUsers = useMemo(() => {
|
|
const q = search.trim().toLowerCase();
|
|
const filtered = users.filter((u) => {
|
|
const fullName = `${u.first_name} ${u.last_name}`.toLowerCase();
|
|
const roleName = getRoleName(u.role_id).toLowerCase();
|
|
const matchesSearch =
|
|
!q ||
|
|
fullName.includes(q) ||
|
|
u.email.toLowerCase().includes(q) ||
|
|
(u.phone || '').toLowerCase().includes(q) ||
|
|
roleName.includes(q);
|
|
const matchesRole = roleFilter ? u.role_id === roleFilter : true;
|
|
return matchesSearch && matchesRole;
|
|
});
|
|
|
|
return [...filtered].sort((a, b) => {
|
|
if (sortBy === 'role') {
|
|
return getRoleName(a.role_id).localeCompare(getRoleName(b.role_id));
|
|
}
|
|
return `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`);
|
|
});
|
|
}, [users, search, roleFilter, sortBy]);
|
|
|
|
const pager = useClientPagination(sortedUsers);
|
|
|
|
const filtersActive = Boolean(roleFilter);
|
|
|
|
const handleExportCSV = () => {
|
|
const headers = ['Name', 'Role', 'Department', 'Email', 'Phone', 'Status'];
|
|
const rows = sortedUsers.map((u) => [
|
|
`${u.first_name} ${u.last_name}`,
|
|
getRoleName(u.role_id),
|
|
getDeptName(u.department_id),
|
|
u.email,
|
|
u.phone || '',
|
|
u.is_active ? 'Active' : 'Inactive',
|
|
]);
|
|
const csv = [headers, ...rows]
|
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
.join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = 'staff-directory.csv';
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
|
|
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 initials = (user: UserResponse) =>
|
|
`${user.first_name.charAt(0).toUpperCase()}${user.last_name.charAt(0).toUpperCase()}`;
|
|
|
|
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">Staff Directory</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">
|
|
{users.length}
|
|
</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-48 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">
|
|
<button
|
|
type="button"
|
|
onClick={handleExportCSV}
|
|
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>
|
|
</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={() => router.push('/users')} 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 staff
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={dataCardShell}>
|
|
<div className="p-4">
|
|
<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(roleFilter);
|
|
setDraftSort(sortBy);
|
|
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-50 w-[240px] 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' },
|
|
...ROLES.map((r) => ({ value: r.id, label: r.name })),
|
|
]}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Sort</label>
|
|
<CustomSelect
|
|
value={draftSort}
|
|
onChange={setDraftSort}
|
|
aria-label="Sort"
|
|
options={[
|
|
{ value: 'name', label: 'Sort by name' },
|
|
{ value: 'role', label: 'Sort by role' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraftRole('');
|
|
setDraftSort('name');
|
|
setRoleFilter('');
|
|
setSortBy('name');
|
|
setShowFilters(false);
|
|
}}
|
|
className={secondaryButton}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setRoleFilter(draftRole);
|
|
setSortBy(draftSort);
|
|
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"
|
|
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>
|
|
|
|
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="py-16 flex justify-center border-t border-border">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : sortedUsers.length === 0 ? (
|
|
<div className="py-16 text-center text-[13px] text-muted-foreground border-t border-border">
|
|
No staff found matching your criteria.
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 p-4 border-t border-border">
|
|
{pager.items.map((user) => {
|
|
const roleName = getRoleName(user.role_id);
|
|
const title = getDesigName(user.designation_id) || roleName;
|
|
return (
|
|
<div
|
|
key={user.user_id}
|
|
className="crm-radius-section border border-border bg-card p-4 min-w-0 shadow-[0_1px_3px_rgba(16,24,40,0.06)] flex flex-col"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-11 h-11 rounded-full bg-primary/10 text-primary text-[13px] font-bold flex items-center justify-center shrink-0">
|
|
{initials(user)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[14px] font-semibold text-foreground truncate">
|
|
{user.first_name} {user.last_name}
|
|
</p>
|
|
<p className="text-[12px] text-muted-foreground truncate">{title}</p>
|
|
</div>
|
|
</div>
|
|
<div className="relative shrink-0" onMouseDown={(e) => e.stopPropagation()}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpenMenuId(openMenuId === user.user_id ? null : user.user_id)}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center text-muted-foreground hover:bg-muted cursor-pointer"
|
|
aria-label="Staff actions"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
{openMenuId === user.user_id && (
|
|
<div className="absolute right-0 top-full mt-1 z-20 w-40 crm-radius-section border border-border bg-card shadow-[0_8px_24px_rgba(16,24,40,0.12)] p-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => router.push('/users')}
|
|
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"
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
Manage user
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 space-y-2 text-[13px] text-muted-foreground">
|
|
<p className="flex items-center gap-2 min-w-0">
|
|
<Mail className="w-3.5 h-3.5 shrink-0" />
|
|
<span className="truncate">{user.email}</span>
|
|
</p>
|
|
<p className="flex items-center gap-2 min-w-0">
|
|
<Phone className="w-3.5 h-3.5 shrink-0" />
|
|
<span className="truncate">{user.phone || '—'}</span>
|
|
</p>
|
|
<p className="flex items-center gap-2 min-w-0">
|
|
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
|
<span className="truncate">{getDeptName(user.department_id)}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold ${
|
|
user.is_active ? 'bg-success text-white' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{user.is_active ? 'Active' : 'Inactive'}
|
|
</span>
|
|
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold bg-warning/15 text-warning">
|
|
{roleName}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-auto pt-3 border-t border-border flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<a
|
|
href={`mailto:${user.email}`}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title="Email"
|
|
>
|
|
<Mail className="w-3.5 h-3.5" />
|
|
</a>
|
|
{user.phone && (
|
|
<a
|
|
href={`tel:${user.phone}`}
|
|
className="w-8 h-8 crm-radius-control flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
title="Call"
|
|
>
|
|
<Phone className="w-3.5 h-3.5" />
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="w-7 h-7 rounded-full bg-primary/10 text-primary text-[10px] font-bold flex items-center justify-center">
|
|
{initials(user)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto border-t border-border">
|
|
<table className="crm-data-table min-w-[860px]">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Staff member</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Role</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Department</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Email</th>
|
|
<th className="px-5 py-3 text-[13px] font-semibold text-gray-700">Last activity</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pager.items.map((user) => (
|
|
<tr key={user.user_id} className="border-b border-border hover:bg-muted/10">
|
|
<td className="px-5 py-3.5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-full bg-primary/10 text-primary text-[12px] font-bold flex items-center justify-center shrink-0">
|
|
{initials(user)}
|
|
</div>
|
|
<span className="text-[13px] font-semibold text-foreground">
|
|
{user.first_name} {user.last_name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-foreground">{getRoleName(user.role_id)}</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{getDeptName(user.department_id)}</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{user.email}</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{user.last_activity || 'No recent activity'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|