feat: Add Staff & Access Management Module with RBAC
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
125
src/features/settings/components/dialogs/CreateSquadDialog.tsx
Normal file
125
src/features/settings/components/dialogs/CreateSquadDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
src/features/settings/components/dialogs/InviteStaffDialog.tsx
Normal file
111
src/features/settings/components/dialogs/InviteStaffDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
190
src/features/settings/components/tabs/TeamTreeView.tsx
Normal file
190
src/features/settings/components/tabs/TeamTreeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/lib/actions/staff-management.ts
Normal file
178
src/lib/actions/staff-management.ts
Normal 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 };
|
||||
}
|
||||
83
src/lib/auth/permissions.ts
Normal file
83
src/lib/auth/permissions.ts
Normal 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
195
src/lib/types/staff.ts
Normal 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' },
|
||||
];
|
||||
Reference in New Issue
Block a user