ifixkart-admin/app/(admin)/staff-directory/page.tsx

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>
);
}