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

@@ -10,7 +10,9 @@ import { PublicAppConfigTab } from './tabs/PublicAppConfig';
import { PartnerGovernanceTab } from './tabs/PartnerGovernance';
import { SystemHealthTab } from './tabs/SystemHealth';
import { PaymentConfigTab } from './tabs/PaymentConfig';
import { Loader2, Settings, Smartphone, Building2, Handshake, Server, CreditCard } from 'lucide-react';
import { TeamTreeView } from './tabs/TeamTreeView';
import { StaffDirectory } from './tabs/StaffDirectory';
import { Loader2, Settings, Smartphone, Building2, Handshake, Server, CreditCard, Network, UserCog } from 'lucide-react';
export function SettingsLayout() {
const [settings, setSettings] = useState<GlobalSettings | null>(null);
@@ -101,6 +103,24 @@ export function SettingsLayout() {
<Server className="h-4 w-4 mr-2" />
System Health
</TabsTrigger>
<div className="w-full h-px bg-border my-2" />
<p className="px-3 text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Access Management</p>
<TabsTrigger
value="teams"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<Network className="h-4 w-4 mr-2" />
Teams
</TabsTrigger>
<TabsTrigger
value="staff"
className="w-full justify-start px-3 py-2.5 data-[state=active]:bg-background data-[state=active]:shadow-sm rounded-md transition-all font-medium"
>
<UserCog className="h-4 w-4 mr-2" />
Staff Directory
</TabsTrigger>
</TabsList>
</Card>
@@ -136,6 +156,12 @@ export function SettingsLayout() {
onUpdate={(data) => handleUpdate('system', data)}
/>
</TabsContent>
<TabsContent value="teams" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<TeamTreeView />
</TabsContent>
<TabsContent value="staff" className="mt-0 space-y-4 animate-in fade-in-50 slide-in-from-bottom-2 duration-300">
<StaffDirectory />
</TabsContent>
</div>
</Tabs>
</div>

View File

@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { getScopesByCategory } from '@/lib/types/staff';
import { createDepartment } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, Plus } from 'lucide-react';
const DEPT_COLORS = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4'];
interface CreateDepartmentDialogProps {
onCreated: () => void;
}
export function CreateDepartmentDialog({ onCreated }: CreateDepartmentDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const [color, setColor] = useState(DEPT_COLORS[0]);
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const scopesByCategory = getScopesByCategory();
const toggleScope = (scope: string) => {
setSelectedScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
};
const handleSubmit = async () => {
if (!name.trim()) { toast.error('Department name is required'); return; }
setLoading(true);
try {
const res = await createDepartment({ name, slug, baseScopes: selectedScopes, color, description });
if (res.success) {
toast.success(`Department "${name}" created`);
setOpen(false);
setName(''); setDescription(''); setSelectedScopes([]);
onCreated();
}
} catch { toast.error('Failed to create department'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" /> New Department
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Department</DialogTitle>
<DialogDescription>Define a new organizational unit with base permissions.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Department Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Customer Support" />
{slug && <p className="text-xs text-muted-foreground">Slug: <code>{slug}</code></p>}
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="What does this department handle?" />
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex gap-2">
{DEPT_COLORS.map(c => (
<button key={c} onClick={() => setColor(c)}
className={`w-8 h-8 rounded-full border-2 transition-transform ${color === c ? 'border-foreground scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
<div className="space-y-3">
<Label>Base Permissions</Label>
<p className="text-xs text-muted-foreground">All members in this department will inherit these permissions.</p>
{Object.entries(scopesByCategory).map(([category, scopes]) => (
<div key={category} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{category}</p>
<div className="grid grid-cols-2 gap-2">
{scopes.map(({ scope, label }) => (
<label key={scope} className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50">
<Checkbox checked={selectedScopes.includes(scope)} onCheckedChange={() => toggleScope(scope)} />
{label}
</label>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Department
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Department, StaffMember, getScopesByCategory } from '@/lib/types/staff';
import { createSquad } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, Plus } from 'lucide-react';
interface CreateSquadDialogProps {
departments: Department[];
staff: StaffMember[];
onCreated: () => void;
}
export function CreateSquadDialog({ departments, staff, onCreated }: CreateSquadDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [departmentId, setDepartmentId] = useState('');
const [managerId, setManagerId] = useState('');
const [selectedScopes, setSelectedScopes] = useState<string[]>([]);
const scopesByCategory = getScopesByCategory();
// Filter staff by selected department for manager selection
const availableManagers = staff.filter(s =>
(s.departmentId === departmentId || !s.departmentId) && s.status === 'active'
);
const toggleScope = (scope: string) => {
setSelectedScopes(prev => prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]);
};
const handleSubmit = async () => {
if (!name.trim()) { toast.error('Squad name is required'); return; }
if (!departmentId) { toast.error('Select a parent department'); return; }
setLoading(true);
try {
const res = await createSquad({ name, departmentId, managerId: managerId || undefined, extraScopes: selectedScopes });
if (res.success) {
toast.success(`Squad "${name}" created`);
setOpen(false);
setName(''); setDepartmentId(''); setManagerId(''); setSelectedScopes([]);
onCreated();
}
} catch { toast.error('Failed to create squad'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4 mr-1" /> New Squad
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Squad</DialogTitle>
<DialogDescription>Create a sub-team within a department with additive permissions.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Squad Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. L1 - First Response" />
</div>
<div className="space-y-2">
<Label>Parent Department</Label>
<Select value={departmentId} onValueChange={setDepartmentId}>
<SelectTrigger><SelectValue placeholder="Select department" /></SelectTrigger>
<SelectContent>
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Squad Manager (Optional)</Label>
<Select value={managerId} onValueChange={setManagerId}>
<SelectTrigger><SelectValue placeholder="Assign a lead" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">No Manager</SelectItem>
{availableManagers.map(s => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Extra Permissions</Label>
<p className="text-xs text-muted-foreground">These are <strong>additive</strong> on top of the department's base scopes.</p>
{Object.entries(scopesByCategory).map(([category, scopes]) => (
<div key={category} className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">{category}</p>
<div className="grid grid-cols-2 gap-2">
{scopes.map(({ scope, label }) => (
<label key={scope} className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50">
<Checkbox checked={selectedScopes.includes(scope)} onCheckedChange={() => toggleScope(scope)} />
{label}
</label>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Squad
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Department, Squad, StaffRole } from '@/lib/types/staff';
import { inviteStaff } from '@/lib/actions/staff-management';
import { toast } from 'sonner';
import { Loader2, UserPlus } from 'lucide-react';
interface InviteStaffDialogProps {
departments: Department[];
squads: Squad[];
onInvited: () => void;
}
export function InviteStaffDialog({ departments, squads, onInvited }: InviteStaffDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [departmentId, setDepartmentId] = useState('');
const [squadId, setSquadId] = useState('');
const [role, setRole] = useState<StaffRole>('MEMBER');
const filteredSquads = squads.filter(s => s.departmentId === departmentId);
const handleSubmit = async () => {
if (!name.trim() || !email.trim()) { toast.error('Name and email are required'); return; }
if (!departmentId || !squadId) { toast.error('Select a department and squad'); return; }
setLoading(true);
try {
const res = await inviteStaff({ name, email, departmentId, squadId, role });
if (res.success) {
toast.success('Invite Sent', { description: res.message });
setOpen(false);
setName(''); setEmail(''); setDepartmentId(''); setSquadId(''); setRole('MEMBER');
onInvited();
} else {
toast.error(res.message);
}
} catch { toast.error('Failed to send invite'); }
finally { setLoading(false); }
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-1" /> Invite Staff
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>Send a magic link invitation to join the team.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Full Name</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Neha Sharma" />
</div>
<div className="space-y-2">
<Label>Email Address</Label>
<Input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="neha@eventify.com" />
</div>
<div className="space-y-2">
<Label>Department</Label>
<Select value={departmentId} onValueChange={(v) => { setDepartmentId(v); setSquadId(''); }}>
<SelectTrigger><SelectValue placeholder="Select department" /></SelectTrigger>
<SelectContent>
{departments.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Squad</Label>
<Select value={squadId} onValueChange={setSquadId} disabled={!departmentId}>
<SelectTrigger><SelectValue placeholder={departmentId ? "Select squad" : "Select department first"} /></SelectTrigger>
<SelectContent>
{filteredSquads.map(s => <SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as StaffRole)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="MANAGER">Manager</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Send Invitation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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

View File

@@ -0,0 +1,178 @@
import { Department, Squad, StaffMember, StaffRole, MOCK_DEPARTMENTS, MOCK_SQUADS, MOCK_STAFF } from '../types/staff';
// ===== In-Memory Mock DB =====
let departments: Department[] = [...MOCK_DEPARTMENTS];
let squads: Squad[] = [...MOCK_SQUADS];
let staff: StaffMember[] = [...MOCK_STAFF];
// Hydrate from localStorage
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem('mock_staff_data');
if (saved) {
const parsed = JSON.parse(saved);
departments = parsed.departments || MOCK_DEPARTMENTS;
squads = parsed.squads || MOCK_SQUADS;
staff = parsed.staff || MOCK_STAFF;
}
} catch (e) { /* ignore */ }
}
function persist() {
if (typeof window !== 'undefined') {
localStorage.setItem('mock_staff_data', JSON.stringify({ departments, squads, staff }));
}
}
function uid() { return 'id_' + Math.random().toString(36).substring(2, 11); }
// ===== DEPARTMENT ACTIONS =====
export async function getDepartments(): Promise<{ success: boolean; data: Department[] }> {
await new Promise(r => setTimeout(r, 300));
return { success: true, data: departments };
}
export async function createDepartment(data: { name: string; slug: string; baseScopes: string[]; color: string; description?: string }): Promise<{ success: boolean; department: Department }> {
await new Promise(r => setTimeout(r, 500));
const dept: Department = {
id: uid(),
name: data.name,
slug: data.slug,
description: data.description || '',
baseScopes: data.baseScopes,
color: data.color,
createdAt: new Date().toISOString(),
};
departments.push(dept);
persist();
return { success: true, department: dept };
}
export async function deleteDepartment(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const deptSquads = squads.filter(s => s.departmentId === id);
if (deptSquads.length > 0) {
return { success: false, message: 'Cannot delete department with active squads. Remove squads first.' };
}
departments = departments.filter(d => d.id !== id);
persist();
return { success: true, message: 'Department deleted.' };
}
// ===== SQUAD ACTIONS =====
export async function getSquads(departmentId?: string): Promise<{ success: boolean; data: Squad[] }> {
await new Promise(r => setTimeout(r, 300));
const filtered = departmentId ? squads.filter(s => s.departmentId === departmentId) : squads;
return { success: true, data: filtered };
}
export async function createSquad(data: { name: string; departmentId: string; managerId?: string; extraScopes: string[] }): Promise<{ success: boolean; squad: Squad }> {
await new Promise(r => setTimeout(r, 500));
const sq: Squad = {
id: uid(),
name: data.name,
departmentId: data.departmentId,
managerId: data.managerId || null,
extraScopes: data.extraScopes,
createdAt: new Date().toISOString(),
};
squads.push(sq);
// If manager is specified, update their role
if (data.managerId) {
staff = staff.map(s => s.id === data.managerId ? { ...s, role: 'MANAGER' as StaffRole, squadId: sq.id, departmentId: data.departmentId } : s);
}
persist();
return { success: true, squad: sq };
}
export async function deleteSquad(id: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 400));
const members = staff.filter(s => s.squadId === id);
if (members.length > 0) {
return { success: false, message: 'Cannot delete squad with active members. Move or remove members first.' };
}
squads = squads.filter(s => s.id !== id);
persist();
return { success: true, message: 'Squad dissolved.' };
}
// ===== STAFF ACTIONS =====
export async function getStaff(): Promise<{ success: boolean; data: StaffMember[] }> {
await new Promise(r => setTimeout(r, 300));
return { success: true, data: staff };
}
export async function inviteStaff(data: { name: string; email: string; departmentId: string; squadId: string; role: StaffRole }): Promise<{ success: boolean; member: StaffMember; message: string }> {
await new Promise(r => setTimeout(r, 800));
// Check for duplicates
if (staff.find(s => s.email === data.email)) {
return { success: false, member: null as any, message: 'A staff member with this email already exists.' };
}
const member: StaffMember = {
id: uid(),
name: data.name,
email: data.email,
squadId: data.squadId,
departmentId: data.departmentId,
role: data.role,
status: 'invited',
joinedAt: new Date().toISOString(),
};
staff.push(member);
persist();
console.log(`[AUDIT] Staff Invited: ${data.email} -> ${data.departmentId}/${data.squadId} as ${data.role}`);
return { success: true, member, message: `Invitation sent to ${data.email}` };
}
export async function updateStaffRole(staffId: string, newRole: StaffRole): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
staff = staff.map(s => s.id === staffId ? { ...s, role: newRole } : s);
persist();
return { success: true, message: 'Role updated.' };
}
export async function moveStaff(staffId: string, newSquadId: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
const squad = squads.find(s => s.id === newSquadId);
if (!squad) return { success: false, message: 'Target squad not found.' };
staff = staff.map(s => s.id === staffId ? { ...s, squadId: newSquadId, departmentId: squad.departmentId } : s);
persist();
console.log(`[AUDIT] Staff ${staffId} moved to squad ${newSquadId}`);
return { success: true, message: `Moved to ${squad.name}` };
}
export async function deactivateStaff(staffId: string): Promise<{ success: boolean; message: string }> {
await new Promise(r => setTimeout(r, 500));
staff = staff.map(s => s.id === staffId ? { ...s, status: 'deactivated' as const } : s);
persist();
return { success: true, message: 'Staff member deactivated.' };
}
// ===== HELPER QUERIES =====
export async function getOrgTree(): Promise<{
success: boolean;
data: Array<Department & { squads: Array<Squad & { members: StaffMember[] }> }>;
}> {
await new Promise(r => setTimeout(r, 400));
const tree = departments.map(dept => ({
...dept,
squads: squads
.filter(sq => sq.departmentId === dept.id)
.map(sq => ({
...sq,
members: staff.filter(s => s.squadId === sq.id && s.status !== 'deactivated'),
})),
}));
return { success: true, data: tree };
}

View File

@@ -0,0 +1,83 @@
import { StaffMember, Department, Squad, MOCK_DEPARTMENTS, MOCK_SQUADS } from '../types/staff';
/**
* Resolves all effective permissions for a staff member.
* Logic: Department.baseScopes + Squad.extraScopes (Additive)
*/
export function getEffectiveScopes(
staff: StaffMember,
departments: Department[] = MOCK_DEPARTMENTS,
squads: Squad[] = MOCK_SQUADS
): string[] {
// SuperAdmin gets everything
if (staff.role === 'SUPER_ADMIN') return ['*'];
const scopeSet = new Set<string>();
// 1. Department base scopes
if (staff.departmentId) {
const dept = departments.find(d => d.id === staff.departmentId);
if (dept) {
dept.baseScopes.forEach(s => scopeSet.add(s));
}
}
// 2. Squad extra scopes (additive)
if (staff.squadId) {
const squad = squads.find(s => s.id === staff.squadId);
if (squad) {
squad.extraScopes.forEach(s => scopeSet.add(s));
}
}
// 3. Manager role bonus: can manage squad members
if (staff.role === 'MANAGER') {
scopeSet.add('settings.staff'); // Can manage their own squad's staff
}
return Array.from(scopeSet);
}
/**
* Main permission check.
*
* Usage: hasPermission(currentUser, 'finance.refunds.execute')
*
* Supports wildcard matching:
* - '*' = full access (SuperAdmin)
* - 'finance.*' = all finance scopes
* - 'finance.refunds.execute' = exact match
*/
export function hasPermission(
staff: StaffMember,
requiredScope: string,
departments?: Department[],
squads?: Squad[]
): boolean {
const effectiveScopes = getEffectiveScopes(staff, departments, squads);
// Wildcard = full access
if (effectiveScopes.includes('*')) return true;
// Exact match
if (effectiveScopes.includes(requiredScope)) return true;
// Category wildcard: 'finance.*' matches 'finance.refunds.execute'
for (const scope of effectiveScopes) {
if (scope.endsWith('.*')) {
const prefix = scope.replace('.*', '');
if (requiredScope.startsWith(prefix)) return true;
}
}
return false;
}
/**
* Check if a staff member is a manager of a specific squad.
*/
export function isSquadManager(staff: StaffMember, squadId: string, squads: Squad[] = MOCK_SQUADS): boolean {
if (staff.role === 'SUPER_ADMIN') return true;
const squad = squads.find(s => s.id === squadId);
return squad?.managerId === staff.id;
}

195
src/lib/types/staff.ts Normal file
View File

@@ -0,0 +1,195 @@
// ===== HIERARCHICAL STAFF & RBAC TYPES =====
export type StaffRole = 'SUPER_ADMIN' | 'MANAGER' | 'MEMBER';
export type StaffStatus = 'active' | 'invited' | 'deactivated';
export type Permission = string; // Dotted notation: "finance.refunds.execute"
export interface Department {
id: string;
name: string;
slug: string;
description?: string;
baseScopes: Permission[];
color: string; // For UI badges
createdAt: string;
}
export interface Squad {
id: string;
name: string;
departmentId: string;
managerId: string | null;
extraScopes: Permission[];
createdAt: string;
}
export interface StaffMember {
id: string;
name: string;
email: string;
avatar?: string;
squadId: string | null;
departmentId: string | null;
role: StaffRole;
status: StaffStatus;
joinedAt: string;
}
// ===== SCOPE DEFINITIONS (Human-readable) =====
export const SCOPE_DEFINITIONS: Record<string, { label: string; category: string }> = {
// Users Module
'users.read': { label: 'View Users', category: 'Users' },
'users.write': { label: 'Edit Users', category: 'Users' },
'users.delete': { label: 'Delete Users', category: 'Users' },
'users.ban': { label: 'Ban/Suspend Users', category: 'Users' },
// Events Module
'events.read': { label: 'View Events', category: 'Events' },
'events.write': { label: 'Create/Edit Events', category: 'Events' },
'events.approve': { label: 'Approve Events', category: 'Events' },
'events.delete': { label: 'Delete Events', category: 'Events' },
// Finance Module
'finance.read': { label: 'View Finance Dashboard', category: 'Finance' },
'finance.refunds.read': { label: 'View Refund Requests', category: 'Finance' },
'finance.refunds.execute': { label: 'Process Refunds', category: 'Finance' },
'finance.payouts.read': { label: 'View Payouts', category: 'Finance' },
'finance.payouts.execute': { label: 'Execute Payouts', category: 'Finance' },
// Partners Module
'partners.read': { label: 'View Partners', category: 'Partners' },
'partners.write': { label: 'Edit Partners', category: 'Partners' },
'partners.kyc': { label: 'Verify Partner KYC', category: 'Partners' },
// Support Module
'tickets.read': { label: 'View Tickets', category: 'Support' },
'tickets.write': { label: 'Respond to Tickets', category: 'Support' },
'tickets.assign': { label: 'Assign Tickets', category: 'Support' },
'tickets.escalate': { label: 'Escalate Tickets', category: 'Support' },
// Settings Module
'settings.read': { label: 'View Settings', category: 'Settings' },
'settings.write': { label: 'Modify Settings', category: 'Settings' },
'settings.staff': { label: 'Manage Staff', category: 'Settings' },
};
export const ALL_SCOPES = Object.keys(SCOPE_DEFINITIONS);
// Group scopes by category for UI
export function getScopesByCategory(): Record<string, { scope: string; label: string }[]> {
const grouped: Record<string, { scope: string; label: string }[]> = {};
for (const [scope, def] of Object.entries(SCOPE_DEFINITIONS)) {
if (!grouped[def.category]) grouped[def.category] = [];
grouped[def.category].push({ scope, label: def.label });
}
return grouped;
}
// ===== MOCK SEED DATA =====
export const MOCK_DEPARTMENTS: Department[] = [
{
id: 'dept_support',
name: 'Customer Support',
slug: 'support',
description: 'Handles user queries, tickets, and escalations.',
baseScopes: ['users.read', 'tickets.read', 'tickets.write'],
color: '#3B82F6',
createdAt: '2025-01-15T10:00:00Z',
},
{
id: 'dept_finance',
name: 'Finance & Accounting',
slug: 'finance',
description: 'Manages refunds, payouts, and financial reporting.',
baseScopes: ['finance.read', 'finance.refunds.read', 'finance.payouts.read'],
color: '#10B981',
createdAt: '2025-01-15T10:00:00Z',
},
{
id: 'dept_ops',
name: 'Operations',
slug: 'operations',
description: 'Event approvals, partner management, and platform ops.',
baseScopes: ['events.read', 'events.write', 'events.approve', 'partners.read', 'partners.write'],
color: '#F59E0B',
createdAt: '2025-02-01T10:00:00Z',
},
{
id: 'dept_tech',
name: 'Technology',
slug: 'tech',
description: 'Engineering, DevOps, and system administration.',
baseScopes: ['settings.read', 'settings.write', 'settings.staff'],
color: '#8B5CF6',
createdAt: '2025-02-10T10:00:00Z',
},
];
export const MOCK_SQUADS: Squad[] = [
// Support squads
{
id: 'squad_l1',
name: 'L1 - First Response',
departmentId: 'dept_support',
managerId: 'staff_sarah',
extraScopes: ['tickets.assign'],
createdAt: '2025-01-20T10:00:00Z',
},
{
id: 'squad_l2',
name: 'L2 - Escalations',
departmentId: 'dept_support',
managerId: 'staff_rahul',
extraScopes: ['tickets.escalate', 'users.write'],
createdAt: '2025-01-20T10:00:00Z',
},
// Finance squads
{
id: 'squad_refunds',
name: 'Refunds Team',
departmentId: 'dept_finance',
managerId: 'staff_priya',
extraScopes: ['finance.refunds.execute'],
createdAt: '2025-02-01T10:00:00Z',
},
{
id: 'squad_payouts',
name: 'Payouts & Settlements',
departmentId: 'dept_finance',
managerId: null,
extraScopes: ['finance.payouts.execute'],
createdAt: '2025-02-05T10:00:00Z',
},
// Ops squads
{
id: 'squad_events_review',
name: 'Event Review',
departmentId: 'dept_ops',
managerId: 'staff_amit',
extraScopes: ['events.delete'],
createdAt: '2025-02-10T10:00:00Z',
},
{
id: 'squad_kyc',
name: 'KYC Verification',
departmentId: 'dept_ops',
managerId: null,
extraScopes: ['partners.kyc'],
createdAt: '2025-02-10T10:00:00Z',
},
];
export const MOCK_STAFF: StaffMember[] = [
{ id: 'staff_admin', name: 'Arjun Mehta', email: 'arjun@eventify.com', avatar: '', squadId: null, departmentId: null, role: 'SUPER_ADMIN', status: 'active', joinedAt: '2024-06-01T10:00:00Z' },
// Support
{ id: 'staff_sarah', name: 'Sarah Khan', email: 'sarah@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MANAGER', status: 'active', joinedAt: '2025-01-20T10:00:00Z' },
{ id: 'staff_rahul', name: 'Rahul Verma', email: 'rahul@eventify.com', avatar: '', squadId: 'squad_l2', departmentId: 'dept_support', role: 'MANAGER', status: 'active', joinedAt: '2025-01-22T10:00:00Z' },
{ id: 'staff_neha', name: 'Neha Sharma', email: 'neha@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MEMBER', status: 'active', joinedAt: '2025-02-01T10:00:00Z' },
{ id: 'staff_vikram', name: 'Vikram Iyer', email: 'vikram@eventify.com', avatar: '', squadId: 'squad_l1', departmentId: 'dept_support', role: 'MEMBER', status: 'active', joinedAt: '2025-02-05T10:00:00Z' },
{ id: 'staff_aisha', name: 'Aisha Patel', email: 'aisha@eventify.com', avatar: '', squadId: 'squad_l2', departmentId: 'dept_support', role: 'MEMBER', status: 'invited', joinedAt: '2025-02-08T10:00:00Z' },
// Finance
{ id: 'staff_priya', name: 'Priya Nair', email: 'priya@eventify.com', avatar: '', squadId: 'squad_refunds', departmentId: 'dept_finance', role: 'MANAGER', status: 'active', joinedAt: '2025-02-01T10:00:00Z' },
{ id: 'staff_deepak', name: 'Deepak Joshi', email: 'deepak@eventify.com', avatar: '', squadId: 'squad_refunds', departmentId: 'dept_finance', role: 'MEMBER', status: 'active', joinedAt: '2025-02-03T10:00:00Z' },
{ id: 'staff_meera', name: 'Meera Gupta', email: 'meera@eventify.com', avatar: '', squadId: 'squad_payouts', departmentId: 'dept_finance', role: 'MEMBER', status: 'active', joinedAt: '2025-02-06T10:00:00Z' },
// Ops
{ id: 'staff_amit', name: 'Amit Desai', email: 'amit@eventify.com', avatar: '', squadId: 'squad_events_review', departmentId: 'dept_ops', role: 'MANAGER', status: 'active', joinedAt: '2025-02-10T10:00:00Z' },
{ id: 'staff_ritu', name: 'Ritu Singh', email: 'ritu@eventify.com', avatar: '', squadId: 'squad_kyc', departmentId: 'dept_ops', role: 'MEMBER', status: 'active', joinedAt: '2025-02-10T10:00:00Z' },
{ id: 'staff_karan', name: 'Karan Reddy', email: 'karan@eventify.com', avatar: '', squadId: 'squad_events_review', departmentId: 'dept_ops', role: 'MEMBER', status: 'invited', joinedAt: '2025-02-10T10:00:00Z' },
];