feat: Add Staff & Access Management Module with RBAC
This commit is contained in:
203
src/features/settings/components/tabs/StaffDirectory.tsx
Normal file
203
src/features/settings/components/tabs/StaffDirectory.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user