204 lines
13 KiB
TypeScript
204 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Department, Squad, StaffMember, SCOPE_DEFINITIONS } from '@/lib/types/staff';
|
|
import { getStaff, getDepartments, getSquads, updateStaffRole, moveStaff, deactivateStaff } from '@/lib/actions/staff-management';
|
|
import { getEffectiveScopes } from '@/lib/auth/permissions';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
|
import { InviteStaffDialog } from '../dialogs/InviteStaffDialog';
|
|
import { toast } from 'sonner';
|
|
import { Loader2, Search, MoreHorizontal, UserCog, ArrowRightLeft, UserX, Crown, Shield, ShieldCheck, Eye } from 'lucide-react';
|
|
|
|
export function StaffDirectory() {
|
|
const [staff, setStaff] = useState<StaffMember[]>([]);
|
|
const [departments, setDepartments] = useState<Department[]>([]);
|
|
const [squads, setSquads] = useState<Squad[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [filterDept, setFilterDept] = useState('all');
|
|
const [filterRole, setFilterRole] = useState('all');
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [staffRes, deptRes, sqRes] = await Promise.all([getStaff(), getDepartments(), getSquads()]);
|
|
if (staffRes.success) setStaff(staffRes.data);
|
|
if (deptRes.success) setDepartments(deptRes.data);
|
|
if (sqRes.success) setSquads(sqRes.data);
|
|
} finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => { loadData(); }, []);
|
|
|
|
const getDeptName = (id: string | null) => departments.find(d => d.id === id)?.name || '—';
|
|
const getDeptColor = (id: string | null) => departments.find(d => d.id === id)?.color || '#666';
|
|
const getSquadName = (id: string | null) => squads.find(s => s.id === id)?.name || '—';
|
|
|
|
const handleRoleChange = async (staffId: string, newRole: 'MANAGER' | 'MEMBER') => {
|
|
const res = await updateStaffRole(staffId, newRole);
|
|
if (res.success) { toast.success(res.message); loadData(); }
|
|
else toast.error(res.message);
|
|
};
|
|
|
|
const handleMove = async (staffId: string, newSquadId: string) => {
|
|
const res = await moveStaff(staffId, newSquadId);
|
|
if (res.success) { toast.success(res.message); loadData(); }
|
|
else toast.error(res.message);
|
|
};
|
|
|
|
const handleDeactivate = async (staffId: string) => {
|
|
const res = await deactivateStaff(staffId);
|
|
if (res.success) { toast.success('Staff deactivated'); loadData(); }
|
|
else toast.error(res.message);
|
|
};
|
|
|
|
const filtered = staff.filter(s => {
|
|
if (search && !s.name.toLowerCase().includes(search.toLowerCase()) && !s.email.toLowerCase().includes(search.toLowerCase())) return false;
|
|
if (filterDept !== 'all' && s.departmentId !== filterDept) return false;
|
|
if (filterRole !== 'all' && s.role !== filterRole) return false;
|
|
return true;
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
|
<Loader2 className="h-6 w-6 animate-spin mr-2" /> Loading staff directory...
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold">Staff Directory</h2>
|
|
<p className="text-sm text-muted-foreground">{staff.filter(s => s.status === 'active').length} active, {staff.filter(s => s.status === 'invited').length} invited</p>
|
|
</div>
|
|
<InviteStaffDialog departments={departments} squads={squads} onInvited={loadData} />
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input placeholder="Search by name or email..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" />
|
|
</div>
|
|
<Select value={filterDept} onValueChange={setFilterDept}>
|
|
<SelectTrigger className="w-[180px]"><SelectValue placeholder="Department" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Departments</SelectItem>
|
|
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={filterRole} onValueChange={setFilterRole}>
|
|
<SelectTrigger className="w-[140px]"><SelectValue placeholder="Role" /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Roles</SelectItem>
|
|
<SelectItem value="SUPER_ADMIN">Super Admin</SelectItem>
|
|
<SelectItem value="MANAGER">Manager</SelectItem>
|
|
<SelectItem value="MEMBER">Member</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<Card>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b text-left text-xs uppercase tracking-wider text-muted-foreground">
|
|
<th className="px-4 py-3">Name</th>
|
|
<th className="px-4 py-3">Email</th>
|
|
<th className="px-4 py-3">Department</th>
|
|
<th className="px-4 py-3">Squad</th>
|
|
<th className="px-4 py-3">Role</th>
|
|
<th className="px-4 py-3">Status</th>
|
|
<th className="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{filtered.map(member => (
|
|
<tr key={member.id} className="hover:bg-muted/30 transition-colors">
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary/30 to-primary/5 flex items-center justify-center text-xs font-bold text-primary">
|
|
{member.name.split(' ').map(n => n[0]).join('')}
|
|
</div>
|
|
<span className="font-medium text-sm">{member.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">{member.email}</td>
|
|
<td className="px-4 py-3">
|
|
{member.departmentId ? (
|
|
<Badge variant="outline" className="text-[11px] gap-1" style={{ borderColor: getDeptColor(member.departmentId) + '60', color: getDeptColor(member.departmentId) }}>
|
|
{getDeptName(member.departmentId)}
|
|
</Badge>
|
|
) : <span className="text-sm text-muted-foreground">—</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm">{getSquadName(member.squadId)}</td>
|
|
<td className="px-4 py-3">
|
|
{member.role === 'SUPER_ADMIN' && <Badge className="bg-red-500/10 text-red-600 border-red-200 text-[10px]"><ShieldCheck className="h-3 w-3 mr-1" />Super Admin</Badge>}
|
|
{member.role === 'MANAGER' && <Badge className="bg-amber-500/10 text-amber-600 border-amber-200 text-[10px]"><Crown className="h-3 w-3 mr-1" />Manager</Badge>}
|
|
{member.role === 'MEMBER' && <Badge variant="outline" className="text-[10px]">Member</Badge>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{member.status === 'active' && <Badge variant="outline" className="text-[10px] border-emerald-300 text-emerald-600">Active</Badge>}
|
|
{member.status === 'invited' && <Badge variant="outline" className="text-[10px] border-sky-300 text-sky-600">Invited</Badge>}
|
|
{member.status === 'deactivated' && <Badge variant="outline" className="text-[10px] border-red-300 text-red-500">Deactivated</Badge>}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
{member.role !== 'SUPER_ADMIN' && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuLabel>Manage</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => handleRoleChange(member.id, member.role === 'MANAGER' ? 'MEMBER' : 'MANAGER')}>
|
|
<UserCog className="h-4 w-4 mr-2" />
|
|
{member.role === 'MANAGER' ? 'Demote to Member' : 'Promote to Manager'}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<ArrowRightLeft className="h-4 w-4 mr-2" /> Move to Squad
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent>
|
|
{squads.filter(s => s.id !== member.squadId).map(sq => (
|
|
<DropdownMenuItem key={sq.id} onClick={() => handleMove(member.id, sq.id)}>
|
|
{sq.name}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
<DropdownMenuSeparator />
|
|
{member.status !== 'deactivated' && (
|
|
<DropdownMenuItem className="text-destructive" onClick={() => handleDeactivate(member.id)}>
|
|
<UserX className="h-4 w-4 mr-2" /> Deactivate
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<tr><td colSpan={7} className="text-center py-8 text-muted-foreground">No staff found.</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|