diff --git a/src/features/settings/components/SettingsLayout.tsx b/src/features/settings/components/SettingsLayout.tsx index 02d8685..3502d0b 100644 --- a/src/features/settings/components/SettingsLayout.tsx +++ b/src/features/settings/components/SettingsLayout.tsx @@ -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(null); @@ -101,6 +103,24 @@ export function SettingsLayout() { System Health + +
+

Access Management

+ + + + Teams + + + + Staff Directory + @@ -136,6 +156,12 @@ export function SettingsLayout() { onUpdate={(data) => handleUpdate('system', data)} /> + + + + + +
diff --git a/src/features/settings/components/dialogs/CreateDepartmentDialog.tsx b/src/features/settings/components/dialogs/CreateDepartmentDialog.tsx new file mode 100644 index 0000000..b5916a0 --- /dev/null +++ b/src/features/settings/components/dialogs/CreateDepartmentDialog.tsx @@ -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([]); + 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 ( + + + + + + + Create Department + Define a new organizational unit with base permissions. + + +
+
+ + setName(e.target.value)} placeholder="e.g. Customer Support" /> + {slug &&

Slug: {slug}

} +
+ +
+ + setDescription(e.target.value)} placeholder="What does this department handle?" /> +
+ +
+ +
+ {DEPT_COLORS.map(c => ( +
+
+ +
+ +

All members in this department will inherit these permissions.

+ {Object.entries(scopesByCategory).map(([category, scopes]) => ( +
+

{category}

+
+ {scopes.map(({ scope, label }) => ( + + ))} +
+
+ ))} +
+
+ + + + + +
+
+ ); +} diff --git a/src/features/settings/components/dialogs/CreateSquadDialog.tsx b/src/features/settings/components/dialogs/CreateSquadDialog.tsx new file mode 100644 index 0000000..a74fbb6 --- /dev/null +++ b/src/features/settings/components/dialogs/CreateSquadDialog.tsx @@ -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([]); + + 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 ( + + + + + + + Create Squad + Create a sub-team within a department with additive permissions. + + +
+
+ + setName(e.target.value)} placeholder="e.g. L1 - First Response" /> +
+ +
+ + +
+ +
+ + +
+ +
+ +

These are additive on top of the department's base scopes.

+ {Object.entries(scopesByCategory).map(([category, scopes]) => ( +
+

{category}

+
+ {scopes.map(({ scope, label }) => ( + + ))} +
+
+ ))} +
+
+ + + + + +
+
+ ); +} diff --git a/src/features/settings/components/dialogs/InviteStaffDialog.tsx b/src/features/settings/components/dialogs/InviteStaffDialog.tsx new file mode 100644 index 0000000..84d96e0 --- /dev/null +++ b/src/features/settings/components/dialogs/InviteStaffDialog.tsx @@ -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('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 ( + + + + + + + Invite Team Member + Send a magic link invitation to join the team. + + +
+
+ + setName(e.target.value)} placeholder="e.g. Neha Sharma" /> +
+
+ + setEmail(e.target.value)} placeholder="neha@eventify.com" /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+ ); +} diff --git a/src/features/settings/components/tabs/StaffDirectory.tsx b/src/features/settings/components/tabs/StaffDirectory.tsx new file mode 100644 index 0000000..b543716 --- /dev/null +++ b/src/features/settings/components/tabs/StaffDirectory.tsx @@ -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([]); + const [departments, setDepartments] = useState([]); + const [squads, setSquads] = useState([]); + 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 ( +
+ Loading staff directory... +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Staff Directory

+

{staff.filter(s => s.status === 'active').length} active, {staff.filter(s => s.status === 'invited').length} invited

+
+ +
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} className="pl-9" /> +
+ + +
+ + {/* Table */} + +
+ + + + + + + + + + + + + + {filtered.map(member => ( + + + + + + + + + + ))} + {filtered.length === 0 && ( + + )} + +
NameEmailDepartmentSquadRoleStatusActions
+
+
+ {member.name.split(' ').map(n => n[0]).join('')} +
+ {member.name} +
+
{member.email} + {member.departmentId ? ( + + {getDeptName(member.departmentId)} + + ) : } + {getSquadName(member.squadId)} + {member.role === 'SUPER_ADMIN' && Super Admin} + {member.role === 'MANAGER' && Manager} + {member.role === 'MEMBER' && Member} + + {member.status === 'active' && Active} + {member.status === 'invited' && Invited} + {member.status === 'deactivated' && Deactivated} + + {member.role !== 'SUPER_ADMIN' && ( + + + + + + Manage + + handleRoleChange(member.id, member.role === 'MANAGER' ? 'MEMBER' : 'MANAGER')}> + + {member.role === 'MANAGER' ? 'Demote to Member' : 'Promote to Manager'} + + + + Move to Squad + + + {squads.filter(s => s.id !== member.squadId).map(sq => ( + handleMove(member.id, sq.id)}> + {sq.name} + + ))} + + + + {member.status !== 'deactivated' && ( + handleDeactivate(member.id)}> + Deactivate + + )} + + + )} +
No staff found.
+
+
+
+ ); +} diff --git a/src/features/settings/components/tabs/TeamTreeView.tsx b/src/features/settings/components/tabs/TeamTreeView.tsx new file mode 100644 index 0000000..28dc00a --- /dev/null +++ b/src/features/settings/components/tabs/TeamTreeView.tsx @@ -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([]); + const [allStaff, setAllStaff] = useState([]); + const [allSquads, setAllSquads] = useState([]); + const [loading, setLoading] = useState(true); + const [expandedDepts, setExpandedDepts] = useState>(new Set()); + const [expandedSquads, setExpandedSquads] = useState>(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 ( +
+ Loading organization tree... +
+ ); + } + + return ( +
+ {/* Actions */} +
+
+

Organization Chart

+

Manage departments, squads, and team assignments.

+
+
+ + + +
+
+ + {/* Tree */} +
+ {tree.map(dept => ( + + {/* Department Level */} + + + {/* Scope badges */} + {expandedDepts.has(dept.id) && ( +
+ {dept.baseScopes.map(scope => ( + + {SCOPE_DEFINITIONS[scope]?.label || scope} + + ))} +
+ )} + + {/* Squad Level */} + {expandedDepts.has(dept.id) && ( +
+ {dept.squads.length === 0 && ( +
No squads yet. Create one to get started.
+ )} + {dept.squads.map(squad => { + const manager = squad.members.find(m => m.id === squad.managerId); + return ( +
+ + + {/* Extra scopes */} + {expandedSquads.has(squad.id) && squad.extraScopes.length > 0 && ( +
+ {squad.extraScopes.map(scope => ( + + + {SCOPE_DEFINITIONS[scope]?.label || scope} + + ))} +
+ )} + + {/* Members */} + {expandedSquads.has(squad.id) && ( +
+ {squad.members.map(member => ( +
+
+ {member.name.split(' ').map(n => n[0]).join('')} +
+
+ {member.name} + {member.email} +
+ {member.role === 'MANAGER' && ( + Manager + )} + {member.status === 'invited' && ( + Invited + )} +
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/lib/actions/staff-management.ts b/src/lib/actions/staff-management.ts new file mode 100644 index 0000000..d4dfb42 --- /dev/null +++ b/src/lib/actions/staff-management.ts @@ -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 }>; +}> { + 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 }; +} diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts new file mode 100644 index 0000000..6a6ea38 --- /dev/null +++ b/src/lib/auth/permissions.ts @@ -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(); + + // 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; +} diff --git a/src/lib/types/staff.ts b/src/lib/types/staff.ts new file mode 100644 index 0000000..094f5b8 --- /dev/null +++ b/src/lib/types/staff.ts @@ -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 = { + // 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 { + const grouped: Record = {}; + 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' }, +];