feat: Add Staff & Access Management Module with RBAC

This commit is contained in:
CycroftX
2026-02-10 12:04:44 +05:30
parent 0c8593ef22
commit f180b3d7d2
9 changed files with 1227 additions and 1 deletions

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

View File

@@ -0,0 +1,190 @@
'use client';
import { useState, useEffect } from 'react';
import { Department, Squad, StaffMember, SCOPE_DEFINITIONS } from '@/lib/types/staff';
import { getOrgTree, getStaff } from '@/lib/actions/staff-management';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { CreateDepartmentDialog } from '../dialogs/CreateDepartmentDialog';
import { CreateSquadDialog } from '../dialogs/CreateSquadDialog';
import { InviteStaffDialog } from '../dialogs/InviteStaffDialog';
import { Loader2, ChevronRight, ChevronDown, Building2, Users, UserCircle, Crown, Shield, Plus } from 'lucide-react';
type OrgNode = Department & { squads: (Squad & { members: StaffMember[] })[] };
export function TeamTreeView() {
const [tree, setTree] = useState<OrgNode[]>([]);
const [allStaff, setAllStaff] = useState<StaffMember[]>([]);
const [allSquads, setAllSquads] = useState<Squad[]>([]);
const [loading, setLoading] = useState(true);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [expandedSquads, setExpandedSquads] = useState<Set<string>>(new Set());
const loadData = async () => {
setLoading(true);
try {
const [treeRes, staffRes] = await Promise.all([getOrgTree(), getStaff()]);
if (treeRes.success) {
setTree(treeRes.data);
// Collect all squads from tree for dialogs
const sq: Squad[] = [];
treeRes.data.forEach(d => d.squads.forEach(s => sq.push(s)));
setAllSquads(sq);
// Auto-expand first department
if (treeRes.data.length > 0) {
setExpandedDepts(new Set([treeRes.data[0].id]));
}
}
if (staffRes.success) setAllStaff(staffRes.data);
} finally { setLoading(false); }
};
useEffect(() => { loadData(); }, []);
const toggleDept = (id: string) => {
setExpandedDepts(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleSquad = (id: string) => {
setExpandedSquads(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
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 organization tree...
</div>
);
}
return (
<div className="space-y-6">
{/* Actions */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Organization Chart</h2>
<p className="text-sm text-muted-foreground">Manage departments, squads, and team assignments.</p>
</div>
<div className="flex gap-2">
<CreateDepartmentDialog onCreated={loadData} />
<CreateSquadDialog departments={tree} staff={allStaff} onCreated={loadData} />
<InviteStaffDialog departments={tree} squads={allSquads} onInvited={loadData} />
</div>
</div>
{/* Tree */}
<div className="space-y-3">
{tree.map(dept => (
<Card key={dept.id} className="overflow-hidden">
{/* Department Level */}
<button
className="w-full flex items-center gap-3 p-4 hover:bg-muted/30 transition-colors text-left"
onClick={() => toggleDept(dept.id)}
>
{expandedDepts.has(dept.id) ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<div className="h-8 w-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: dept.color + '20' }}>
<Building2 className="h-4 w-4" style={{ color: dept.color }} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{dept.name}</span>
<Badge variant="outline" className="text-[10px]">{dept.slug}</Badge>
</div>
{dept.description && <p className="text-xs text-muted-foreground">{dept.description}</p>}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" />
{dept.squads.reduce((acc, s) => acc + s.members.length, 0)} members
</div>
</button>
{/* Scope badges */}
{expandedDepts.has(dept.id) && (
<div className="px-4 pb-2 flex flex-wrap gap-1">
{dept.baseScopes.map(scope => (
<Badge key={scope} variant="secondary" className="text-[10px] font-normal">
{SCOPE_DEFINITIONS[scope]?.label || scope}
</Badge>
))}
</div>
)}
{/* Squad Level */}
{expandedDepts.has(dept.id) && (
<div className="border-t">
{dept.squads.length === 0 && (
<div className="p-4 text-sm text-muted-foreground text-center">No squads yet. Create one to get started.</div>
)}
{dept.squads.map(squad => {
const manager = squad.members.find(m => m.id === squad.managerId);
return (
<div key={squad.id} className="border-b last:border-b-0">
<button
className="w-full flex items-center gap-3 pl-12 pr-4 py-3 hover:bg-muted/20 transition-colors text-left"
onClick={() => toggleSquad(squad.id)}
>
{expandedSquads.has(squad.id) ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="font-medium text-sm flex-1">{squad.name}</span>
{manager && (
<Badge variant="outline" className="text-[10px] gap-1">
<Crown className="h-3 w-3 text-amber-500" />{manager.name}
</Badge>
)}
<span className="text-xs text-muted-foreground">{squad.members.length} members</span>
</button>
{/* Extra scopes */}
{expandedSquads.has(squad.id) && squad.extraScopes.length > 0 && (
<div className="pl-20 pr-4 pb-1 flex flex-wrap gap-1">
{squad.extraScopes.map(scope => (
<Badge key={scope} className="text-[10px] bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
+ {SCOPE_DEFINITIONS[scope]?.label || scope}
</Badge>
))}
</div>
)}
{/* Members */}
{expandedSquads.has(squad.id) && (
<div className="pl-20 pr-4 pb-3 space-y-1">
{squad.members.map(member => (
<div key={member.id} className="flex items-center gap-3 py-1.5 px-3 rounded-md hover:bg-muted/30">
<div className="h-7 w-7 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center text-xs font-bold text-primary">
{member.name.split(' ').map(n => n[0]).join('')}
</div>
<div className="flex-1">
<span className="text-sm font-medium">{member.name}</span>
<span className="text-xs text-muted-foreground ml-2">{member.email}</span>
</div>
{member.role === 'MANAGER' && (
<Badge className="text-[10px] bg-amber-500/10 text-amber-600 border-amber-200">Manager</Badge>
)}
{member.status === 'invited' && (
<Badge variant="outline" className="text-[10px] border-sky-300 text-sky-600">Invited</Badge>
)}
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
))}
</div>
</div>
);
}