← back to Norma
app/supervisor/page.tsx
578 lines
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import {
Shield, Users, Globe, ChevronRight, X, Activity,
Mail, Newspaper, ScrollText, Search, Target, Building2,
BarChart3, Settings, Zap, Eye, ArrowRight, Bot,
UserPlus, FileText, MessageCircle, Database,
} from 'lucide-react';
import GlobalSearch from '@/components/GlobalSearch';
/* ─── Types ────────────────────────────────────────────────────────────── */
interface OrgData {
id: string;
name: string;
city: string | null;
state: string | null;
type: string;
}
interface AgentNode {
id: string;
label: string;
tier: 'supervisor' | 'admin' | 'staff' | 'public';
description: string;
icon: typeof Shield;
color: string;
bg: string;
status: 'online' | 'idle' | 'offline';
stats: { label: string; value: string }[];
path: string;
orgId?: string;
children?: string[];
}
/* ─── Agent Registry ───────────────────────────────────────────────────── */
function prioritizeOrgs(orgs: OrgData[]): OrgData[] {
// SDCC Center first, then NAACP, then rest alphabetical
const priority = ['Student Debt Crisis Center', 'NAACP', 'Student Debt Crisis'];
const prioritized = priority
.map(name => orgs.find(o => o.name.trim() === name))
.filter(Boolean) as OrgData[];
const prioritizedIds = new Set(prioritized.map(o => o.id));
const rest = orgs
.filter(o => !prioritizedIds.has(o.id))
.sort((a, b) => a.name.localeCompare(b.name));
return [...prioritized, ...rest];
}
function buildAgentTree(orgs: OrgData[]): AgentNode[] {
const sorted = prioritizeOrgs(orgs);
const agents: AgentNode[] = [
{
id: 'supervisor',
label: 'Supervisor',
tier: 'supervisor',
description: 'System overseer. Monitors all tiers, orgs, and agent health.',
icon: Eye,
color: '#f59e0b',
bg: 'rgba(245,158,11,0.10)',
status: 'online',
stats: [
{ label: 'Tiers', value: '3' },
{ label: 'Orgs', value: String(orgs.length) },
{ label: 'Agents', value: '6' },
],
path: '/supervisor',
children: ['norma'],
},
{
id: 'norma',
label: 'Norma',
tier: 'admin',
description: 'Admin tier. Full platform access: settings, credentials, all orgs, analytics.',
icon: Shield,
color: '#0a7c59',
bg: 'rgba(10,124,89,0.10)',
status: 'online',
stats: [
{ label: 'Tabs', value: '20+' },
{ label: 'APIs', value: '40+' },
{ label: 'Role', value: 'Admin' },
],
path: '/',
children: sorted.slice(0, 6).map(o => `nikki-${o.id}`),
},
];
// Nikki + Pulse instances per org
for (const org of sorted.slice(0, 6)) {
agents.push({
id: `nikki-${org.id}`,
label: `Nikki`,
tier: 'staff',
description: `Staff tier for ${org.name}. Org-locked dashboard, email, petitions, news.`,
icon: Users,
color: '#4f46e5',
bg: 'rgba(79,70,229,0.10)',
status: 'online',
stats: [
{ label: 'Org', value: org.name.length > 20 ? org.name.slice(0, 18) + '...' : org.name },
{ label: 'Location', value: org.state || '—' },
{ label: 'Role', value: 'Staff' },
],
path: '/',
orgId: org.id,
children: [`pulse-${org.id}`],
});
agents.push({
id: `pulse-${org.id}`,
label: `Pulse`,
tier: 'public',
description: `Public tier for ${org.name}. Petition feed, activity history, community.`,
icon: Globe,
color: '#e11d48',
bg: 'rgba(225,29,72,0.10)',
status: 'online',
stats: [
{ label: 'Org', value: org.name.length > 20 ? org.name.slice(0, 18) + '...' : org.name },
{ label: 'Access', value: 'Public' },
{ label: 'Role', value: 'User' },
],
path: '/pulse',
orgId: org.id,
});
}
return agents;
}
/* ─── Sub-agents (functional modules within each tier) ──────────────── */
const SUB_AGENTS: Record<string, { label: string; icon: typeof Mail; color: string }[]> = {
norma: [
{ label: 'Email Center', icon: Mail, color: '#0a7c59' },
{ label: 'News Intel', icon: Newspaper, color: '#4f46e5' },
{ label: 'Petitions', icon: ScrollText, color: '#e11d48' },
{ label: 'Grants', icon: Search, color: '#f59e0b' },
{ label: 'Congress', icon: Building2, color: '#8b5cf6' },
{ label: 'Analytics', icon: BarChart3, color: '#06b6d4' },
{ label: 'Settings', icon: Settings, color: '#6b7280' },
{ label: 'AI Chat', icon: Bot, color: '#0a7c59' },
],
staff: [
{ label: 'Email Sends', icon: Mail, color: '#4f46e5' },
{ label: 'Petitions', icon: ScrollText, color: '#e11d48' },
{ label: 'News', icon: Newspaper, color: '#0a7c59' },
{ label: 'Contacts', icon: UserPlus, color: '#f59e0b' },
{ label: 'Gmail CRM', icon: MessageCircle, color: '#06b6d4' },
{ label: 'Congress', icon: Building2, color: '#8b5cf6' },
],
public: [
{ label: 'Petition Feed', icon: ScrollText, color: '#e11d48' },
{ label: 'Activity', icon: Activity, color: '#0a7c59' },
{ label: 'Community', icon: Users, color: '#4f46e5' },
{ label: 'Reports', icon: FileText, color: '#f59e0b' },
],
};
/* ─── Flow Connector SVG ────────────────────────────────────────────── */
function FlowLine({ from, to }: { from: string; to: string }) {
return (
<div style={{
width: 2,
height: 32,
background: 'var(--color-border)',
margin: '0 auto',
}} />
);
}
/* ─── Agent Card ────────────────────────────────────────────────────── */
function AgentCard({
agent,
isSelected,
onClick,
}: {
agent: AgentNode;
isSelected: boolean;
onClick: () => void;
}) {
const Icon = agent.icon;
const statusColor = agent.status === 'online' ? '#22c55e' : agent.status === 'idle' ? '#f59e0b' : '#ef4444';
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
style={{
background: isSelected ? agent.bg : 'var(--color-surface)',
border: `2px solid ${isSelected ? agent.color : 'var(--color-border)'}`,
borderRadius: 'var(--radius-lg)',
padding: '16px 20px',
cursor: 'pointer',
textAlign: 'left',
width: agent.tier === 'supervisor' ? 220 : agent.tier === 'admin' ? 200 : 160,
position: 'relative',
transition: 'border-color 150ms ease, background 150ms ease',
boxShadow: isSelected ? `0 0 20px ${agent.color}22` : 'none',
}}
>
{/* Status dot */}
<div style={{
position: 'absolute', top: 10, right: 10,
width: 8, height: 8, borderRadius: '50%',
background: statusColor,
boxShadow: `0 0 6px ${statusColor}`,
}} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<div style={{
width: 36, height: 36, borderRadius: 'var(--radius-md)',
background: agent.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon size={20} color={agent.color} />
</div>
<div>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text)' }}>
{agent.label}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{agent.tier}
</div>
</div>
</div>
{/* Stats row */}
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{agent.stats.map(s => (
<div key={s.label} style={{ fontSize: 11 }}>
<span style={{ color: 'var(--color-text-muted)' }}>{s.label}: </span>
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 600 }}>{s.value}</span>
</div>
))}
</div>
</motion.button>
);
}
/* ─── Detail Panel ──────────────────────────────────────────────────── */
function DetailPanel({
agent,
onClose,
onEnter,
}: {
agent: AgentNode;
onClose: () => void;
onEnter: () => void;
}) {
const Icon = agent.icon;
const tierKey = agent.tier === 'admin' ? 'norma' : agent.tier === 'staff' ? 'staff' : agent.tier === 'public' ? 'public' : 'norma';
const subAgents = SUB_AGENTS[tierKey] || [];
return (
<motion.div
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
style={{
position: 'fixed', top: 0, right: 0, bottom: 0,
width: 380, background: 'var(--color-surface)',
borderLeft: '1px solid var(--color-border)',
boxShadow: '-8px 0 30px rgba(0,0,0,0.08)',
zIndex: 50, overflow: 'auto',
padding: 28,
}}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div style={{ display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: agent.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon size={26} color={agent.color} />
</div>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, margin: 0, color: 'var(--color-text)' }}>{agent.label}</h2>
<span style={{
fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.08em',
color: agent.color, fontWeight: 600,
}}>{agent.tier} tier</span>
</div>
</div>
<button onClick={onClose} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 4,
color: 'var(--color-text-muted)',
}}>
<X size={20} />
</button>
</div>
{/* Description */}
<p style={{ fontSize: 14, lineHeight: 1.6, color: 'var(--color-text-secondary)', margin: '0 0 24px' }}>
{agent.description}
</p>
{/* Stats */}
<div style={{
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 10, marginBottom: 24,
}}>
{agent.stats.map(s => (
<div key={s.label} style={{
background: 'var(--color-surface-el)', borderRadius: 'var(--radius-sm)',
padding: '10px 12px', textAlign: 'center',
}}>
<div style={{ fontSize: 16, fontWeight: 700, color: agent.color }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
{/* Status */}
<div style={{
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 24,
padding: '10px 14px', background: 'rgba(34,197,94,0.08)', borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(34,197,94,0.2)',
}}>
<Activity size={16} color="#22c55e" />
<span style={{ fontSize: 13, fontWeight: 600, color: '#22c55e' }}>Online</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', marginLeft: 'auto' }}>Port 7400</span>
</div>
{/* Sub-agents / modules */}
{subAgents.length > 0 && (
<>
<h3 style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '0 0 12px' }}>
Modules
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 28 }}>
{subAgents.map(sa => {
const SaIcon = sa.icon;
return (
<div key={sa.label} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 'var(--radius-sm)',
background: 'var(--color-surface-el)',
}}>
<SaIcon size={16} color={sa.color} />
<span style={{ fontSize: 13, color: 'var(--color-text)', fontWeight: 500 }}>{sa.label}</span>
</div>
);
})}
</div>
</>
)}
{/* Enter button */}
{agent.tier !== 'supervisor' && (
<button
onClick={onEnter}
style={{
width: '100%', padding: '14px 20px',
background: agent.color, color: '#fff',
border: 'none', borderRadius: 'var(--radius-md)',
fontSize: 15, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
transition: 'opacity 150ms ease',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = '0.9')}
onMouseLeave={e => (e.currentTarget.style.opacity = '1')}
>
Enter {agent.label}
<ArrowRight size={18} />
</button>
)}
</motion.div>
);
}
/* ─── Main Supervisor Page ──────────────────────────────────────────── */
export default function SupervisorPage() {
const router = useRouter();
const [orgs, setOrgs] = useState<OrgData[]>([]);
const [agents, setAgents] = useState<AgentNode[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Escape key closes panel
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setSelectedId(null);
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
useEffect(() => {
(async () => {
try {
const res = await fetch('/api/orgs?limit=200&sort=name&sort_dir=asc');
if (res.ok) {
const data = await res.json();
const orgRows = data.rows || [];
setOrgs(orgRows);
setAgents(buildAgentTree(orgRows));
}
} catch {
// fallback with empty orgs
setAgents(buildAgentTree([]));
}
setLoading(false);
})();
}, []);
const selectedAgent = agents.find(a => a.id === selectedId) || null;
const supervisorAgent = agents.find(a => a.id === 'supervisor');
const normaAgent = agents.find(a => a.id === 'norma');
const nikkiAgents = agents.filter(a => a.tier === 'staff');
const pulseAgents = agents.filter(a => a.tier === 'public');
function handleEnter(agent: AgentNode) {
const url = agent.orgId ? `${agent.path}?org=${agent.orgId}` : agent.path;
router.push(url);
}
if (loading) {
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-bg)',
}}>
<div className="spinner" />
</div>
);
}
return (
<div style={{
minHeight: '100vh', background: 'var(--color-bg)',
padding: '40px 32px', position: 'relative',
}}>
{/* Top-right global search — supervisors jump to any entity (Cmd+K) */}
<div style={{
position: 'absolute', top: 24, right: 32, zIndex: 40,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<GlobalSearch onNavigate={(tab, id) => {
// Supervisor lives at /supervisor; entity tabs live in Norma admin shell (/).
// Forward to the admin shell with the target tab + entity id as query params.
const params = new URLSearchParams({ tab });
if (id) params.set('id', id);
router.push(`/?${params.toString()}`);
}} />
</div>
{/* Page header */}
<div style={{ maxWidth: 1200, margin: '0 auto 40px', textAlign: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 8 }}>
<Eye size={28} color="#f59e0b" />
<h1 style={{ fontSize: 28, fontWeight: 800, color: 'var(--color-text)', margin: 0 }}>
Supervisor Agent
</h1>
</div>
<p style={{ fontSize: 15, color: 'var(--color-text-secondary)', maxWidth: 500, margin: '0 auto' }}>
System overview. Click any agent node to inspect, then enter its dashboard.
</p>
</div>
{/* Org Chart Flow */}
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Level 0: Supervisor */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 0 }}>
{supervisorAgent && (
<AgentCard
agent={supervisorAgent}
isSelected={selectedId === 'supervisor'}
onClick={() => setSelectedId(selectedId === 'supervisor' ? null : 'supervisor')}
/>
)}
</div>
{/* Connector */}
<FlowLine from="supervisor" to="norma" />
{/* Level 1: Norma (Admin) */}
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 0 }}>
{normaAgent && (
<AgentCard
agent={normaAgent}
isSelected={selectedId === 'norma'}
onClick={() => setSelectedId(selectedId === 'norma' ? null : 'norma')}
/>
)}
</div>
{/* Connector fan-out */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{
width: Math.min(nikkiAgents.length * 180, 1000),
height: 32,
borderBottom: '2px solid var(--color-border)',
borderLeft: '2px solid var(--color-border)',
borderRight: '2px solid var(--color-border)',
borderRadius: '0 0 var(--radius-md) var(--radius-md)',
position: 'relative',
}}>
{/* Vertical line from Norma */}
<div style={{
position: 'absolute', top: -2, left: '50%', transform: 'translateX(-50%)',
width: 2, height: 2, background: 'var(--color-border)',
}} />
</div>
</div>
{/* Level 2+3: Paired Nikki → Pulse columns per org */}
<div style={{
display: 'flex', justifyContent: 'center', gap: 20, flexWrap: 'wrap',
}}>
{nikkiAgents.map(agent => {
const pulseAgent = pulseAgents.find(p => p.orgId === agent.orgId);
return (
<div key={agent.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{/* Nikki card */}
<AgentCard
agent={agent}
isSelected={selectedId === agent.id}
onClick={() => setSelectedId(selectedId === agent.id ? null : agent.id)}
/>
{/* Vertical connector */}
{pulseAgent && (
<>
<div style={{
width: 2, height: 28,
background: `linear-gradient(to bottom, ${agent.color}40, ${pulseAgent.color}40)`,
}} />
{/* Pulse card */}
<AgentCard
agent={pulseAgent}
isSelected={selectedId === pulseAgent.id}
onClick={() => setSelectedId(selectedId === pulseAgent.id ? null : pulseAgent.id)}
/>
</>
)}
</div>
);
})}
</div>
</div>
{/* Detail panel slideout */}
<AnimatePresence>
{selectedAgent && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.15)',
zIndex: 40,
}}
/>
<DetailPanel
agent={selectedAgent}
onClose={() => setSelectedId(null)}
onEnter={() => handleEnter(selectedAgent)}
/>
</>
)}
</AnimatePresence>
</div>
);
}