feat(governance): Migrate Governance Engine to Next.js (apps/web)
BREAKING: Replace old MicroDAO voting with Agent Governance Engine ## New Files - apps/web/src/lib/types/governance.ts - apps/web/src/lib/api/governance.ts - apps/web/src/lib/api/audit.ts - apps/web/src/lib/api/incidents.ts - apps/web/src/components/governance/GovernanceLevelBadge.tsx - apps/web/src/components/governance/ReportButton.tsx - apps/web/src/components/governance/CityGovernancePanel.tsx - apps/web/src/components/governance/AuditDashboard.tsx - apps/web/src/components/governance/IncidentsList.tsx - apps/web/src/app/audit/page.tsx - apps/web/src/app/incidents/page.tsx ## Updated Files - apps/web/src/app/governance/page.tsx - New City Governance UI - apps/web/src/components/Navigation.tsx - Shield icon for Governance ## Task docs/tasks/TASK_PHASE_GOVERNANCE_MIGRATION_NEXTJS.md
This commit is contained in:
15
apps/web/src/app/audit/page.tsx
Normal file
15
apps/web/src/app/audit/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AuditDashboard } from '@/components/governance/AuditDashboard';
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function AuditPage() {
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<AuditDashboard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,204 +1,14 @@
|
||||
import Link from 'next/link'
|
||||
import { Wallet, Users, Vote, FileText, TrendingUp, Shield, ArrowRight } from 'lucide-react'
|
||||
import { api, MicroDAO } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CityGovernancePanel } from '@/components/governance/CityGovernancePanel';
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getMicroDAOs(): Promise<MicroDAO[]> {
|
||||
try {
|
||||
return await api.getMicroDAOs()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MicroDAOs:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function GovernancePage() {
|
||||
const daos = await getMicroDAOs()
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function GovernancePage() {
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/20">
|
||||
<Wallet className="w-8 h-8 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Governance</h1>
|
||||
<p className="text-slate-400">MicroDAO управління та голосування</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard icon={Users} label="MicroDAOs" value={daos.length.toString()} color="amber" />
|
||||
<StatCard icon={Vote} label="Активних пропозицій" value="0" color="cyan" />
|
||||
<StatCard icon={FileText} label="Всього пропозицій" value="0" color="violet" />
|
||||
<StatCard icon={TrendingUp} label="Участь" value="0%" color="emerald" />
|
||||
</div>
|
||||
|
||||
{/* MicroDAOs List */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-amber-400" />
|
||||
Ваші MicroDAO
|
||||
</h2>
|
||||
|
||||
{daos.length === 0 ? (
|
||||
<div className="glass-panel p-12 text-center">
|
||||
<Wallet className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
MicroDAO не знайдено
|
||||
</h3>
|
||||
<p className="text-slate-400 mb-6">
|
||||
Ви ще не є учасником жодного MicroDAO.
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-gradient-to-r from-amber-500 to-orange-600 rounded-xl font-medium text-white hover:from-amber-400 hover:to-orange-500 transition-all">
|
||||
Створити MicroDAO
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{daos.map((dao) => (
|
||||
<DAOCard key={dao.id} dao={dao} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Швидкі дії</h2>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<ActionCard
|
||||
icon={Vote}
|
||||
title="Голосувати"
|
||||
description="Переглянути активні пропозиції та проголосувати"
|
||||
href="/governance/proposals"
|
||||
/>
|
||||
<ActionCard
|
||||
icon={FileText}
|
||||
title="Створити пропозицію"
|
||||
description="Запропонувати зміни для вашого MicroDAO"
|
||||
href="/governance/create-proposal"
|
||||
/>
|
||||
<ActionCard
|
||||
icon={Users}
|
||||
title="Учасники"
|
||||
description="Переглянути членів та їх ролі"
|
||||
href="/governance/members"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CityGovernancePanel />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
value: string
|
||||
color: 'amber' | 'cyan' | 'violet' | 'emerald'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
amber: 'text-amber-400',
|
||||
cyan: 'text-cyan-400',
|
||||
violet: 'text-violet-400',
|
||||
emerald: 'text-emerald-400'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel p-4">
|
||||
<Icon className={cn('w-5 h-5 mb-2', colorClasses[color])} />
|
||||
<div className="text-2xl font-bold text-white">{value}</div>
|
||||
<div className="text-xs text-slate-400">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DAOCard({ dao }: { dao: MicroDAO }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/governance/${dao.id}`}
|
||||
className="glass-panel-hover p-6 group block"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500/30 to-orange-600/30 flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-amber-400 transition-colors">
|
||||
{dao.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">{dao.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 text-xs rounded-full',
|
||||
dao.is_active
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: 'bg-slate-700/50 text-slate-400'
|
||||
)}>
|
||||
{dao.is_active ? 'Активний' : 'Неактивний'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-400 mb-4 line-clamp-2">
|
||||
{dao.description || 'Без опису'}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
0 учасників
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Vote className="w-3 h-3" />
|
||||
0 пропозицій
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="w-5 h-5 text-slate-500 group-hover:text-amber-400 group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
href
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<Icon className="w-8 h-8 text-amber-400 mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-semibold text-white mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-400">{description}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
15
apps/web/src/app/incidents/page.tsx
Normal file
15
apps/web/src/app/incidents/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IncidentsList } from '@/components/governance/IncidentsList';
|
||||
|
||||
// Force dynamic rendering
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function IncidentsPage() {
|
||||
return (
|
||||
<div className="min-h-screen px-4 py-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<IncidentsList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2, Server, Users, Network } from 'lucide-react'
|
||||
import { Menu, X, Home, Building2, User, Sparkles, Bot, Wallet, LogOut, Loader2, Server, Users, Network, Shield } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
@@ -13,7 +13,7 @@ const navItems = [
|
||||
{ href: '/citizens', label: 'Громадяни', icon: Users },
|
||||
{ href: '/agents', label: 'Агенти', icon: Bot },
|
||||
{ href: '/microdao', label: 'MicroDAO', icon: Network },
|
||||
{ href: '/governance', label: 'DAO', icon: Wallet },
|
||||
{ href: '/governance', label: 'Governance', icon: Shield },
|
||||
{ href: '/secondme', label: 'Second Me', icon: User },
|
||||
{ href: '/nodes', label: 'Ноди', icon: Server },
|
||||
]
|
||||
|
||||
188
apps/web/src/components/governance/AuditDashboard.tsx
Normal file
188
apps/web/src/components/governance/AuditDashboard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Calendar, User, Target, Filter, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { auditApi } from '@/lib/api/audit';
|
||||
import type { GovernanceEvent, GovernanceEventType, AuditStats } from '@/lib/types/governance';
|
||||
|
||||
const EVENT_TYPE_LABELS: Partial<Record<GovernanceEventType, string>> = {
|
||||
'agent.promoted': 'Агент підвищений',
|
||||
'agent.demoted': 'Агент понижений',
|
||||
'agent.revoked': 'Агент заблокований',
|
||||
'agent.reinstated': 'Агент відновлений',
|
||||
'incident.created': 'Інцидент створено',
|
||||
'incident.resolved': 'Інцидент вирішено',
|
||||
'microdao.created': 'MicroDAO створено',
|
||||
'node.registered': 'Ноду зареєстровано',
|
||||
'room.created': 'Кімнату створено',
|
||||
};
|
||||
|
||||
const EVENT_TYPE_COLORS: Partial<Record<GovernanceEventType, string>> = {
|
||||
'agent.promoted': 'text-green-400 bg-green-500/20',
|
||||
'agent.demoted': 'text-orange-400 bg-orange-500/20',
|
||||
'agent.revoked': 'text-red-400 bg-red-500/20',
|
||||
'agent.reinstated': 'text-blue-400 bg-blue-500/20',
|
||||
'incident.created': 'text-yellow-400 bg-yellow-500/20',
|
||||
'incident.resolved': 'text-cyan-400 bg-cyan-500/20',
|
||||
'microdao.created': 'text-purple-400 bg-purple-500/20',
|
||||
'node.registered': 'text-pink-400 bg-pink-500/20',
|
||||
'room.created': 'text-amber-400 bg-amber-500/20',
|
||||
};
|
||||
|
||||
export function AuditDashboard() {
|
||||
const [events, setEvents] = useState<GovernanceEvent[]>([]);
|
||||
const [stats, setStats] = useState<AuditStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [eventTypeFilter, setEventTypeFilter] = useState<string>('');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [eventsData, statsData] = await Promise.all([
|
||||
auditApi.getAuditEvents({
|
||||
limit: 50,
|
||||
eventType: eventTypeFilter as GovernanceEventType || undefined,
|
||||
}),
|
||||
auditApi.getAuditStats(),
|
||||
]);
|
||||
setEvents(eventsData);
|
||||
setStats(statsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [eventTypeFilter]);
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleString('uk-UA', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-600/20">
|
||||
<FileText className="w-8 h-8 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Audit Dashboard</h1>
|
||||
<p className="text-slate-400">Журнал подій governance</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("w-5 h-5 text-slate-400", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="glass-panel p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.totalEvents}</div>
|
||||
<div className="text-xs text-slate-400">Всього подій</div>
|
||||
</div>
|
||||
<div className="glass-panel p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{Object.keys(stats.eventsByType).length}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">Типів подій</div>
|
||||
</div>
|
||||
<div className="glass-panel p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">{stats.topActors.length}</div>
|
||||
<div className="text-xs text-slate-400">Активних акторів</div>
|
||||
</div>
|
||||
<div className="glass-panel p-4 text-center">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{stats.eventsByDay.slice(-7).reduce((sum, d) => sum + d.count, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">За 7 днів</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Filter className="w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
value={eventTypeFilter}
|
||||
onChange={(e) => setEventTypeFilter(e.target.value)}
|
||||
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі типи подій</option>
|
||||
{Object.entries(EVENT_TYPE_LABELS).map(([type, label]) => (
|
||||
<option key={type} value={type}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Events List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-cyan-400 animate-spin" />
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="glass-panel p-12 text-center">
|
||||
<FileText className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Немає подій</h3>
|
||||
<p className="text-slate-400">Журнал подій порожній</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="glass-panel p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Event Type Badge */}
|
||||
<span className={cn(
|
||||
'px-2 py-1 text-xs rounded-lg shrink-0',
|
||||
EVENT_TYPE_COLORS[event.eventType] || 'text-slate-400 bg-slate-500/20'
|
||||
)}>
|
||||
{EVENT_TYPE_LABELS[event.eventType] || event.eventType}
|
||||
</span>
|
||||
|
||||
{/* Event Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-4 text-sm text-slate-400 mb-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{event.actorId}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Target className="w-3 h-3" />
|
||||
{event.targetId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{event.scope} • {event.subject}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs text-slate-500 flex items-center gap-1 shrink-0">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(event.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
242
apps/web/src/components/governance/CityGovernancePanel.tsx
Normal file
242
apps/web/src/components/governance/CityGovernancePanel.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
Landmark, Shield, Users, AlertTriangle, FileText,
|
||||
ChevronRight, Loader2, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GovernanceLevelBadge } from './GovernanceLevelBadge';
|
||||
import { governanceApi } from '@/lib/api/governance';
|
||||
import { incidentsApi } from '@/lib/api/incidents';
|
||||
import type { GovernanceAgent, Incident } from '@/lib/types/governance';
|
||||
|
||||
export function CityGovernancePanel() {
|
||||
const [cityAgents, setCityAgents] = useState<GovernanceAgent[]>([]);
|
||||
const [districtLeads, setDistrictLeads] = useState<GovernanceAgent[]>([]);
|
||||
const [openIncidents, setOpenIncidents] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [agents, leads, incidents] = await Promise.all([
|
||||
governanceApi.getCityAgents().catch(() => []),
|
||||
governanceApi.getDistrictLeadAgents().catch(() => []),
|
||||
incidentsApi.getIncidents({
|
||||
escalationLevel: 'city',
|
||||
status: 'open',
|
||||
limit: 5
|
||||
}).catch(() => []),
|
||||
]);
|
||||
|
||||
setCityAgents(agents);
|
||||
setDistrictLeads(leads);
|
||||
setOpenIncidents(incidents);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-amber-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-red-500/20 to-orange-600/20">
|
||||
<Landmark className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">City Governance</h1>
|
||||
<p className="text-slate-400">DAARION.city управління</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
title="Оновити"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/20 border border-red-500/30 rounded-xl text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* City Agents Grid */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-red-400" />
|
||||
City Governance Agents
|
||||
</h2>
|
||||
|
||||
{cityAgents.length === 0 ? (
|
||||
<div className="glass-panel p-8 text-center">
|
||||
<Shield className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-slate-400">Немає city governance агентів</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{cityAgents.map((agent) => (
|
||||
<Link
|
||||
key={agent.id}
|
||||
href={`/agents/${agent.id}`}
|
||||
className="glass-panel p-4 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-red-500/30 to-orange-600/30 flex items-center justify-center text-white text-lg font-bold">
|
||||
{agent.avatarUrl ? (
|
||||
<img src={agent.avatarUrl} alt={agent.displayName} className="w-full h-full rounded-xl object-cover" />
|
||||
) : (
|
||||
agent.displayName.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-white truncate group-hover:text-amber-400 transition-colors">
|
||||
{agent.displayName}
|
||||
</h3>
|
||||
<GovernanceLevelBadge level={agent.govLevel} status={agent.status} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* District Leads */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-pink-400" />
|
||||
District Lead Agents
|
||||
</h2>
|
||||
|
||||
{districtLeads.length === 0 ? (
|
||||
<div className="glass-panel p-6 text-center">
|
||||
<Users className="w-10 h-10 text-slate-600 mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">Немає district lead агентів</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{districtLeads.map((agent) => (
|
||||
<Link
|
||||
key={agent.id}
|
||||
href={`/governance/district/${agent.homeMicrodaoId || agent.id}`}
|
||||
className="glass-panel p-4 hover:bg-white/5 transition-colors group flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-pink-500/30 to-purple-600/30 flex items-center justify-center text-white font-bold">
|
||||
{agent.displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white group-hover:text-pink-400 transition-colors">
|
||||
{agent.displayName}
|
||||
</h3>
|
||||
{agent.homeMicrodaoName && (
|
||||
<p className="text-xs text-slate-400">{agent.homeMicrodaoName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-slate-500 group-hover:text-pink-400 transition-colors" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* City-Level Incidents */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400" />
|
||||
City-Level Incidents
|
||||
</h2>
|
||||
<Link
|
||||
href="/incidents"
|
||||
className="text-sm text-amber-400 hover:text-amber-300 flex items-center gap-1"
|
||||
>
|
||||
Всі інциденти
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{openIncidents.length === 0 ? (
|
||||
<div className="glass-panel p-6 text-center">
|
||||
<AlertTriangle className="w-10 h-10 text-slate-600 mx-auto mb-2" />
|
||||
<p className="text-slate-400 text-sm">Немає відкритих інцидентів рівня City</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{openIncidents.map((incident) => (
|
||||
<Link
|
||||
key={incident.id}
|
||||
href={`/incidents?id=${incident.id}`}
|
||||
className="glass-panel p-4 hover:bg-white/5 transition-colors block"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-white truncate">{incident.title}</h3>
|
||||
<p className="text-sm text-slate-400 line-clamp-1">
|
||||
{incident.description || 'Без опису'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 text-xs rounded-full shrink-0',
|
||||
incident.priority === 'critical' && 'bg-red-500/20 text-red-400',
|
||||
incident.priority === 'high' && 'bg-orange-500/20 text-orange-400',
|
||||
incident.priority === 'medium' && 'bg-yellow-500/20 text-yellow-400',
|
||||
incident.priority === 'low' && 'bg-slate-500/20 text-slate-400'
|
||||
)}>
|
||||
{incident.priority}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/audit"
|
||||
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<FileText className="w-8 h-8 text-cyan-400 mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-semibold text-white mb-1">Audit Dashboard</h3>
|
||||
<p className="text-sm text-slate-400">Переглянути журнал подій governance</p>
|
||||
</Link>
|
||||
<Link
|
||||
href="/incidents"
|
||||
className="glass-panel p-5 hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<AlertTriangle className="w-8 h-8 text-amber-400 mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-semibold text-white mb-1">Incidents</h3>
|
||||
<p className="text-sm text-slate-400">Управління інцидентами та скаргами</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
135
apps/web/src/components/governance/GovernanceLevelBadge.tsx
Normal file
135
apps/web/src/components/governance/GovernanceLevelBadge.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { Shield, User, Users, Briefcase, Star, Crown, Building2, Landmark } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AgentGovLevel, AgentStatus } from '@/lib/types/governance';
|
||||
import { GOV_LEVEL_LABELS, AGENT_STATUS_LABELS } from '@/lib/types/governance';
|
||||
|
||||
interface GovernanceLevelBadgeProps {
|
||||
level: AgentGovLevel;
|
||||
status?: AgentStatus;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LEVEL_CONFIG: Record<AgentGovLevel, {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
}> = {
|
||||
guest: {
|
||||
icon: User,
|
||||
bg: 'bg-slate-500/20',
|
||||
text: 'text-slate-400',
|
||||
border: 'border-slate-500/30',
|
||||
},
|
||||
personal: {
|
||||
icon: User,
|
||||
bg: 'bg-blue-500/20',
|
||||
text: 'text-blue-400',
|
||||
border: 'border-blue-500/30',
|
||||
},
|
||||
member: {
|
||||
icon: Users,
|
||||
bg: 'bg-green-500/20',
|
||||
text: 'text-green-400',
|
||||
border: 'border-green-500/30',
|
||||
},
|
||||
worker: {
|
||||
icon: Briefcase,
|
||||
bg: 'bg-yellow-500/20',
|
||||
text: 'text-yellow-400',
|
||||
border: 'border-yellow-500/30',
|
||||
},
|
||||
core_team: {
|
||||
icon: Star,
|
||||
bg: 'bg-orange-500/20',
|
||||
text: 'text-orange-400',
|
||||
border: 'border-orange-500/30',
|
||||
},
|
||||
orchestrator: {
|
||||
icon: Crown,
|
||||
bg: 'bg-purple-500/20',
|
||||
text: 'text-purple-400',
|
||||
border: 'border-purple-500/30',
|
||||
},
|
||||
district_lead: {
|
||||
icon: Building2,
|
||||
bg: 'bg-pink-500/20',
|
||||
text: 'text-pink-400',
|
||||
border: 'border-pink-500/30',
|
||||
},
|
||||
city_governance: {
|
||||
icon: Landmark,
|
||||
bg: 'bg-red-500/20',
|
||||
text: 'text-red-400',
|
||||
border: 'border-red-500/30',
|
||||
},
|
||||
};
|
||||
|
||||
const SIZE_CLASSES = {
|
||||
sm: {
|
||||
container: 'px-2 py-0.5 text-xs gap-1',
|
||||
icon: 'w-3 h-3',
|
||||
},
|
||||
md: {
|
||||
container: 'px-3 py-1 text-sm gap-1.5',
|
||||
icon: 'w-4 h-4',
|
||||
},
|
||||
lg: {
|
||||
container: 'px-4 py-2 text-base gap-2',
|
||||
icon: 'w-5 h-5',
|
||||
},
|
||||
};
|
||||
|
||||
export function GovernanceLevelBadge({
|
||||
level,
|
||||
status = 'active',
|
||||
showLabel = true,
|
||||
size = 'md',
|
||||
className,
|
||||
}: GovernanceLevelBadgeProps) {
|
||||
const config = LEVEL_CONFIG[level];
|
||||
const sizeClasses = SIZE_CLASSES[size];
|
||||
const Icon = config.icon;
|
||||
|
||||
const isInactive = status !== 'active';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{/* Level Badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border font-medium',
|
||||
sizeClasses.container,
|
||||
isInactive ? 'bg-slate-800/50 text-slate-500 border-slate-700' : config.bg,
|
||||
isInactive ? '' : config.text,
|
||||
isInactive ? '' : config.border
|
||||
)}
|
||||
>
|
||||
<Icon className={cn(sizeClasses.icon, isInactive && 'opacity-50')} />
|
||||
{showLabel && (
|
||||
<span className={isInactive ? 'line-through' : ''}>
|
||||
{GOV_LEVEL_LABELS[level]}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Status Badge (if not active) */}
|
||||
{isInactive && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full text-xs px-2 py-0.5',
|
||||
status === 'suspended' && 'bg-yellow-500/20 text-yellow-400',
|
||||
status === 'revoked' && 'bg-red-500/20 text-red-400'
|
||||
)}
|
||||
>
|
||||
{AGENT_STATUS_LABELS[status]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
279
apps/web/src/components/governance/IncidentsList.tsx
Normal file
279
apps/web/src/components/governance/IncidentsList.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
AlertTriangle, Clock, CheckCircle2, XCircle,
|
||||
Filter, Loader2, RefreshCw, ChevronDown, ChevronUp,
|
||||
ArrowUpRight, User
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { incidentsApi, type IncidentsFilter } from '@/lib/api/incidents';
|
||||
import type {
|
||||
Incident, IncidentStatus, IncidentPriority, EscalationLevel
|
||||
} from '@/lib/types/governance';
|
||||
import {
|
||||
INCIDENT_STATUS_LABELS,
|
||||
INCIDENT_PRIORITY_LABELS,
|
||||
ESCALATION_LABELS,
|
||||
} from '@/lib/types/governance';
|
||||
|
||||
const STATUS_ICONS: Record<IncidentStatus, React.ComponentType<{ className?: string }>> = {
|
||||
open: AlertTriangle,
|
||||
in_progress: Clock,
|
||||
resolved: CheckCircle2,
|
||||
closed: XCircle,
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<IncidentStatus, string> = {
|
||||
open: 'text-yellow-400 bg-yellow-500/20',
|
||||
in_progress: 'text-blue-400 bg-blue-500/20',
|
||||
resolved: 'text-green-400 bg-green-500/20',
|
||||
closed: 'text-slate-400 bg-slate-500/20',
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS: Record<IncidentPriority, string> = {
|
||||
low: 'text-slate-400 bg-slate-500/20',
|
||||
medium: 'text-yellow-400 bg-yellow-500/20',
|
||||
high: 'text-orange-400 bg-orange-500/20',
|
||||
critical: 'text-red-400 bg-red-500/20',
|
||||
};
|
||||
|
||||
export function IncidentsList() {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<IncidentsFilter>({
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await incidentsApi.getIncidents(filter);
|
||||
setIncidents(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load incidents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [filter]);
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleString('uk-UA', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEscalate = async (incident: Incident) => {
|
||||
const nextLevel: EscalationLevel =
|
||||
incident.escalationLevel === 'microdao' ? 'district' :
|
||||
incident.escalationLevel === 'district' ? 'city' : 'city';
|
||||
|
||||
try {
|
||||
await incidentsApi.escalateIncident(
|
||||
incident.id,
|
||||
nextLevel,
|
||||
'dais-demo-user', // TODO: real auth
|
||||
'Ескалація з UI'
|
||||
);
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('Failed to escalate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/20">
|
||||
<AlertTriangle className="w-8 h-8 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Incidents</h1>
|
||||
<p className="text-slate-400">Управління інцидентами</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("w-5 h-5 text-slate-400", loading && "animate-spin")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-slate-400" />
|
||||
<select
|
||||
value={filter.status || ''}
|
||||
onChange={(e) => setFilter(prev => ({
|
||||
...prev,
|
||||
status: e.target.value as IncidentStatus || undefined
|
||||
}))}
|
||||
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі статуси</option>
|
||||
{Object.entries(INCIDENT_STATUS_LABELS).map(([status, label]) => (
|
||||
<option key={status} value={status}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filter.priority || ''}
|
||||
onChange={(e) => setFilter(prev => ({
|
||||
...prev,
|
||||
priority: e.target.value as IncidentPriority || undefined
|
||||
}))}
|
||||
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі пріоритети</option>
|
||||
{Object.entries(INCIDENT_PRIORITY_LABELS).map(([priority, label]) => (
|
||||
<option key={priority} value={priority}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filter.escalationLevel || ''}
|
||||
onChange={(e) => setFilter(prev => ({
|
||||
...prev,
|
||||
escalationLevel: e.target.value as EscalationLevel || undefined
|
||||
}))}
|
||||
className="bg-slate-800/50 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:border-amber-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Всі рівні</option>
|
||||
{Object.entries(ESCALATION_LABELS).map(([level, label]) => (
|
||||
<option key={level} value={level}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Incidents List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-amber-400 animate-spin" />
|
||||
</div>
|
||||
) : incidents.length === 0 ? (
|
||||
<div className="glass-panel p-12 text-center">
|
||||
<AlertTriangle className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Немає інцидентів</h3>
|
||||
<p className="text-slate-400">Список інцидентів порожній</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{incidents.map((incident) => {
|
||||
const StatusIcon = STATUS_ICONS[incident.status];
|
||||
const isExpanded = expandedId === incident.id;
|
||||
|
||||
return (
|
||||
<div key={incident.id} className="glass-panel overflow-hidden">
|
||||
{/* Main Row */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-white/5 transition-colors"
|
||||
onClick={() => setExpandedId(isExpanded ? null : incident.id)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Status Icon */}
|
||||
<div className={cn('p-2 rounded-lg', STATUS_COLORS[incident.status])}>
|
||||
<StatusIcon className="w-5 h-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h3 className="font-semibold text-white truncate">{incident.title}</h3>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 text-xs rounded-full shrink-0',
|
||||
PRIORITY_COLORS[incident.priority]
|
||||
)}>
|
||||
{INCIDENT_PRIORITY_LABELS[incident.priority]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400">
|
||||
<span>{incident.targetScopeType}: {incident.targetScopeId}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
{ESCALATION_LABELS[incident.escalationLevel]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-slate-500">{formatDate(incident.createdAt)}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-2 border-t border-white/5">
|
||||
{incident.description && (
|
||||
<p className="text-sm text-slate-300 mb-4">{incident.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400 mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
Створив: {incident.createdByDaisId}
|
||||
</span>
|
||||
{incident.assignedToDaisId && (
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
Призначено: {incident.assignedToDaisId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{incident.resolution && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg mb-4">
|
||||
<p className="text-sm text-green-400">
|
||||
<strong>Рішення:</strong> {incident.resolution}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{incident.status === 'open' && incident.escalationLevel !== 'city' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEscalate(incident);
|
||||
}}
|
||||
className="px-4 py-2 bg-amber-500/20 text-amber-400 rounded-lg hover:bg-amber-500/30 transition-colors text-sm"
|
||||
>
|
||||
Ескалювати до {
|
||||
incident.escalationLevel === 'microdao' ? 'District' : 'City'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
226
apps/web/src/components/governance/ReportButton.tsx
Normal file
226
apps/web/src/components/governance/ReportButton.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, X, Send, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { incidentsApi } from '@/lib/api/incidents';
|
||||
import type { TargetScopeType, IncidentPriority } from '@/lib/types/governance';
|
||||
import { INCIDENT_PRIORITY_LABELS } from '@/lib/types/governance';
|
||||
|
||||
interface ReportButtonProps {
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
actorDaisId: string;
|
||||
variant?: 'icon' | 'button' | 'text';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReportButton({
|
||||
targetScopeType,
|
||||
targetScopeId,
|
||||
actorDaisId,
|
||||
variant = 'button',
|
||||
className,
|
||||
}: ReportButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [priority, setPriority] = useState<IncidentPriority>('medium');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setError('Введіть заголовок');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await incidentsApi.createIncident({
|
||||
createdByDaisId: actorDaisId,
|
||||
targetScopeType,
|
||||
targetScopeId,
|
||||
priority,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setPriority('medium');
|
||||
setSuccess(false);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Помилка створення інциденту');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonContent = {
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
button: (
|
||||
<>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<span>Поскаржитись</span>
|
||||
</>
|
||||
),
|
||||
text: <span className="text-red-400 hover:text-red-300">Поскаржитись</span>,
|
||||
};
|
||||
|
||||
const buttonClasses = {
|
||||
icon: 'p-2 rounded-lg hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-colors',
|
||||
button: 'flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm',
|
||||
text: 'text-sm hover:underline',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={cn(buttonClasses[variant], className)}
|
||||
title="Створити скаргу"
|
||||
>
|
||||
{buttonContent[variant]}
|
||||
</button>
|
||||
|
||||
{/* Modal */}
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md glass-panel p-6 rounded-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-red-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Створити інцидент</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
{targetScopeType}: {targetScopeId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Send className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-white mb-2">Інцидент створено!</h4>
|
||||
<p className="text-sm text-slate-400">Ваша скарга буде розглянута модераторами</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Заголовок *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Коротко опишіть проблему"
|
||||
className="w-full px-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder:text-slate-500 focus:border-red-500 focus:outline-none"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Пріоритет</label>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high', 'critical'] as IncidentPriority[]).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPriority(p)}
|
||||
className={cn(
|
||||
'flex-1 py-2 text-xs rounded-lg border transition-colors',
|
||||
priority === p
|
||||
? p === 'critical'
|
||||
? 'bg-red-500/30 border-red-500 text-red-400'
|
||||
: p === 'high'
|
||||
? 'bg-orange-500/30 border-orange-500 text-orange-400'
|
||||
: p === 'medium'
|
||||
? 'bg-yellow-500/30 border-yellow-500 text-yellow-400'
|
||||
: 'bg-slate-500/30 border-slate-500 text-slate-400'
|
||||
: 'bg-slate-800/50 border-slate-700 text-slate-500 hover:border-slate-600'
|
||||
)}
|
||||
>
|
||||
{INCIDENT_PRIORITY_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-400 mb-1">Опис (опціонально)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Детальний опис ситуації..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-white placeholder:text-slate-500 focus:border-red-500 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex-1 py-2 rounded-lg border border-slate-700 text-slate-400 hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Скасувати
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Відправка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Відправити
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
11
apps/web/src/components/governance/index.ts
Normal file
11
apps/web/src/components/governance/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Governance Components
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
export { GovernanceLevelBadge } from './GovernanceLevelBadge';
|
||||
export { ReportButton } from './ReportButton';
|
||||
export { CityGovernancePanel } from './CityGovernancePanel';
|
||||
export { AuditDashboard } from './AuditDashboard';
|
||||
export { IncidentsList } from './IncidentsList';
|
||||
|
||||
98
apps/web/src/lib/api/audit.ts
Normal file
98
apps/web/src/lib/api/audit.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Audit API Client for DAARION.city
|
||||
* Based on: backend/http/audit.routes.ts
|
||||
*/
|
||||
|
||||
import type {
|
||||
GovernanceEvent,
|
||||
AuditEventFilter,
|
||||
AuditStats,
|
||||
GovernanceScope,
|
||||
} from '../types/governance';
|
||||
|
||||
// API base URL
|
||||
const getApiBase = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIT EVENTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getAuditEvents(filter?: AuditEventFilter): Promise<GovernanceEvent[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter?.eventType) params.set('eventType', filter.eventType);
|
||||
if (filter?.actorId) params.set('actorId', filter.actorId);
|
||||
if (filter?.targetId) params.set('targetId', filter.targetId);
|
||||
if (filter?.scope) params.set('scope', filter.scope);
|
||||
if (filter?.createdAtFrom) params.set('createdAtFrom', filter.createdAtFrom);
|
||||
if (filter?.createdAtTo) params.set('createdAtTo', filter.createdAtTo);
|
||||
if (filter?.limit) params.set('limit', filter.limit.toString());
|
||||
if (filter?.offset) params.set('offset', filter.offset.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/v1/audit/events?${queryString}` : '/api/v1/audit/events';
|
||||
|
||||
return fetchApi(endpoint);
|
||||
}
|
||||
|
||||
export async function getAuditEventById(id: string): Promise<GovernanceEvent> {
|
||||
return fetchApi(`/api/v1/audit/events/${id}`);
|
||||
}
|
||||
|
||||
export async function getAuditEventsByActor(actorId: string, limit = 50): Promise<GovernanceEvent[]> {
|
||||
return fetchApi(`/api/v1/audit/actor/${actorId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getAuditEventsByTarget(targetId: string, limit = 50): Promise<GovernanceEvent[]> {
|
||||
return fetchApi(`/api/v1/audit/target/${targetId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function getAuditStats(scope?: GovernanceScope): Promise<AuditStats> {
|
||||
const endpoint = scope ? `/api/v1/audit/stats?scope=${scope}` : '/api/v1/audit/stats';
|
||||
return fetchApi(endpoint);
|
||||
}
|
||||
|
||||
export async function getEntityHistory(
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
limit = 50
|
||||
): Promise<GovernanceEvent[]> {
|
||||
return fetchApi(`/api/v1/audit/entity/${entityType}/${entityId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
// Export as namespace
|
||||
export const auditApi = {
|
||||
getAuditEvents,
|
||||
getAuditEventById,
|
||||
getAuditEventsByActor,
|
||||
getAuditEventsByTarget,
|
||||
getAuditStats,
|
||||
getEntityHistory,
|
||||
};
|
||||
|
||||
177
apps/web/src/lib/api/governance.ts
Normal file
177
apps/web/src/lib/api/governance.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Governance API Client for DAARION.city
|
||||
* Based on: backend/http/governance.routes.ts
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentGovLevel,
|
||||
GovernanceScope,
|
||||
AgentRolesResponse,
|
||||
GovernanceAgent,
|
||||
GovernancePower,
|
||||
RevocationType,
|
||||
} from '../types/governance';
|
||||
|
||||
// API base URL for governance endpoints
|
||||
const getApiBase = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AGENT GOVERNANCE
|
||||
// ============================================================================
|
||||
|
||||
export async function promoteAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
newLevel: AgentGovLevel,
|
||||
scope: GovernanceScope,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi('/api/v1/governance/agent/promote', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, targetId, newLevel, scope, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function demoteAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
newLevel: AgentGovLevel,
|
||||
scope: GovernanceScope,
|
||||
reason?: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi('/api/v1/governance/agent/demote', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, targetId, newLevel, scope, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
reason: string,
|
||||
scope: GovernanceScope,
|
||||
revocationType: RevocationType = 'soft'
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi('/api/v1/governance/agent/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, targetId, reason, scope, revocationType }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function suspendAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
reason: string,
|
||||
scope: GovernanceScope
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi('/api/v1/governance/agent/suspend', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, targetId, reason, scope }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function reinstateAgent(
|
||||
actorId: string,
|
||||
targetId: string,
|
||||
scope: GovernanceScope
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi('/api/v1/governance/agent/reinstate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, targetId, scope }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAgentRoles(agentId: string): Promise<AgentRolesResponse> {
|
||||
return fetchApi(`/api/v1/governance/agent/${agentId}/roles`);
|
||||
}
|
||||
|
||||
export async function getAgentPermissions(agentId: string): Promise<GovernancePower[]> {
|
||||
return fetchApi(`/api/v1/governance/agent/${agentId}/permissions`);
|
||||
}
|
||||
|
||||
export async function checkPermission(
|
||||
actorId: string,
|
||||
action: string,
|
||||
targetType: string,
|
||||
targetId: string
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
return fetchApi('/api/v1/governance/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorId, action, targetType, targetId }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CITY GOVERNANCE AGENTS
|
||||
// ============================================================================
|
||||
|
||||
export async function getCityAgents(): Promise<GovernanceAgent[]> {
|
||||
return fetchApi('/api/v1/governance/agents/city');
|
||||
}
|
||||
|
||||
export async function getDistrictLeadAgents(): Promise<GovernanceAgent[]> {
|
||||
return fetchApi('/api/v1/governance/agents/district-leads');
|
||||
}
|
||||
|
||||
export async function getAgentsByLevel(level: AgentGovLevel): Promise<GovernanceAgent[]> {
|
||||
return fetchApi(`/api/v1/governance/agents/by-level/${level}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DAIS KEYS
|
||||
// ============================================================================
|
||||
|
||||
export async function revokeDaisKey(
|
||||
daisId: string,
|
||||
keyId: string,
|
||||
reason: string,
|
||||
revokedBy: string
|
||||
): Promise<{ success: boolean }> {
|
||||
return fetchApi('/api/v1/governance/dais/keys/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ daisId, keyId, reason, revokedBy }),
|
||||
});
|
||||
}
|
||||
|
||||
// Export all functions as a namespace
|
||||
export const governanceApi = {
|
||||
promoteAgent,
|
||||
demoteAgent,
|
||||
revokeAgent,
|
||||
suspendAgent,
|
||||
reinstateAgent,
|
||||
getAgentRoles,
|
||||
getAgentPermissions,
|
||||
checkPermission,
|
||||
getCityAgents,
|
||||
getDistrictLeadAgents,
|
||||
getAgentsByLevel,
|
||||
revokeDaisKey,
|
||||
};
|
||||
|
||||
161
apps/web/src/lib/api/incidents.ts
Normal file
161
apps/web/src/lib/api/incidents.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Incidents API Client for DAARION.city
|
||||
* Based on: backend/http/incidents.routes.ts
|
||||
*/
|
||||
|
||||
import type {
|
||||
Incident,
|
||||
IncidentHistory,
|
||||
IncidentStatus,
|
||||
IncidentPriority,
|
||||
EscalationLevel,
|
||||
TargetScopeType,
|
||||
CreateIncidentRequest,
|
||||
} from '../types/governance';
|
||||
|
||||
// API base URL
|
||||
const getApiBase = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return process.env.INTERNAL_API_URL || 'http://daarion-city-service:7001';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const API_BASE = getApiBase();
|
||||
|
||||
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENTS CRUD
|
||||
// ============================================================================
|
||||
|
||||
export async function createIncident(data: CreateIncidentRequest): Promise<Incident> {
|
||||
return fetchApi('/api/v1/incidents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export interface IncidentsFilter {
|
||||
status?: IncidentStatus;
|
||||
priority?: IncidentPriority;
|
||||
escalationLevel?: EscalationLevel;
|
||||
targetScopeType?: TargetScopeType;
|
||||
targetScopeId?: string;
|
||||
assignedTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function getIncidents(filter?: IncidentsFilter): Promise<Incident[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filter?.status) params.set('status', filter.status);
|
||||
if (filter?.priority) params.set('priority', filter.priority);
|
||||
if (filter?.escalationLevel) params.set('escalationLevel', filter.escalationLevel);
|
||||
if (filter?.targetScopeType) params.set('targetScopeType', filter.targetScopeType);
|
||||
if (filter?.targetScopeId) params.set('targetScopeId', filter.targetScopeId);
|
||||
if (filter?.assignedTo) params.set('assignedTo', filter.assignedTo);
|
||||
if (filter?.limit) params.set('limit', filter.limit.toString());
|
||||
if (filter?.offset) params.set('offset', filter.offset.toString());
|
||||
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/v1/incidents?${queryString}` : '/api/v1/incidents';
|
||||
|
||||
return fetchApi(endpoint);
|
||||
}
|
||||
|
||||
export async function getIncidentById(id: string): Promise<Incident> {
|
||||
return fetchApi(`/api/v1/incidents/${id}`);
|
||||
}
|
||||
|
||||
export async function getIncidentHistory(id: string): Promise<IncidentHistory[]> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/history`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENT ACTIONS
|
||||
// ============================================================================
|
||||
|
||||
export async function assignIncident(
|
||||
id: string,
|
||||
assignedToDaisId: string,
|
||||
actorDaisId: string
|
||||
): Promise<Incident> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ assignedToDaisId, actorDaisId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function escalateIncident(
|
||||
id: string,
|
||||
newLevel: EscalationLevel,
|
||||
actorDaisId: string,
|
||||
reason?: string
|
||||
): Promise<Incident> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/escalate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ newLevel, actorDaisId, reason }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveIncident(
|
||||
id: string,
|
||||
resolution: string,
|
||||
actorDaisId: string
|
||||
): Promise<Incident> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ resolution, actorDaisId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeIncident(id: string, actorDaisId: string): Promise<Incident> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/close`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ actorDaisId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function addIncidentComment(
|
||||
id: string,
|
||||
comment: string,
|
||||
actorDaisId: string
|
||||
): Promise<IncidentHistory> {
|
||||
return fetchApi(`/api/v1/incidents/${id}/comment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment, actorDaisId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Export as namespace
|
||||
export const incidentsApi = {
|
||||
createIncident,
|
||||
getIncidents,
|
||||
getIncidentById,
|
||||
getIncidentHistory,
|
||||
assignIncident,
|
||||
escalateIncident,
|
||||
resolveIncident,
|
||||
closeIncident,
|
||||
addIncidentComment,
|
||||
};
|
||||
|
||||
302
apps/web/src/lib/types/governance.ts
Normal file
302
apps/web/src/lib/types/governance.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Governance Types for DAARION.city
|
||||
* Based on: docs/foundation/Agent_Governance_Protocol_v1.md
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE LEVELS (0-7)
|
||||
// ============================================================================
|
||||
|
||||
export const AGENT_LEVELS = {
|
||||
GUEST: 0,
|
||||
PERSONAL: 1,
|
||||
MEMBER: 2,
|
||||
WORKER: 3,
|
||||
CORE_TEAM: 4,
|
||||
ORCHESTRATOR: 5,
|
||||
DISTRICT_LEAD: 6,
|
||||
CITY_GOVERNANCE: 7,
|
||||
} as const;
|
||||
|
||||
export type AgentLevelNum = typeof AGENT_LEVELS[keyof typeof AGENT_LEVELS];
|
||||
|
||||
export type AgentGovLevel =
|
||||
| 'guest'
|
||||
| 'personal'
|
||||
| 'member'
|
||||
| 'worker'
|
||||
| 'core_team'
|
||||
| 'orchestrator'
|
||||
| 'district_lead'
|
||||
| 'city_governance';
|
||||
|
||||
export const GOV_LEVEL_LABELS: Record<AgentGovLevel, string> = {
|
||||
guest: 'Guest',
|
||||
personal: 'Personal',
|
||||
member: 'Member',
|
||||
worker: 'Worker',
|
||||
core_team: 'Core Team',
|
||||
orchestrator: 'Orchestrator',
|
||||
district_lead: 'District Lead',
|
||||
city_governance: 'City Governance',
|
||||
};
|
||||
|
||||
export const GOV_LEVEL_COLORS: Record<AgentGovLevel, string> = {
|
||||
guest: 'slate',
|
||||
personal: 'blue',
|
||||
member: 'green',
|
||||
worker: 'yellow',
|
||||
core_team: 'orange',
|
||||
orchestrator: 'purple',
|
||||
district_lead: 'pink',
|
||||
city_governance: 'red',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AGENT STATUS
|
||||
// ============================================================================
|
||||
|
||||
export type AgentStatus = 'active' | 'suspended' | 'revoked';
|
||||
|
||||
export const AGENT_STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
active: 'Активний',
|
||||
suspended: 'Призупинено',
|
||||
revoked: 'Заблоковано',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE POWERS
|
||||
// ============================================================================
|
||||
|
||||
export type GovernancePower =
|
||||
| 'administrative'
|
||||
| 'moderation'
|
||||
| 'execution'
|
||||
| 'infrastructure'
|
||||
| 'identity'
|
||||
| 'protocol'
|
||||
| 'district';
|
||||
|
||||
export const POWER_LABELS: Record<GovernancePower, string> = {
|
||||
administrative: 'Адміністрування',
|
||||
moderation: 'Модерація',
|
||||
execution: 'Виконання',
|
||||
infrastructure: 'Інфраструктура',
|
||||
identity: 'Ідентичність',
|
||||
protocol: 'Протокол',
|
||||
district: 'Район',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE SCOPE
|
||||
// ============================================================================
|
||||
|
||||
export type GovernanceScope = 'city' | `district:${string}` | `microdao:${string}` | `node:${string}`;
|
||||
|
||||
// ============================================================================
|
||||
// REVOCATION
|
||||
// ============================================================================
|
||||
|
||||
export type RevocationType = 'soft' | 'hard' | 'shadow';
|
||||
|
||||
export const REVOCATION_LABELS: Record<RevocationType, string> = {
|
||||
soft: 'Тимчасове',
|
||||
hard: 'Постійне',
|
||||
shadow: 'Тіньове',
|
||||
};
|
||||
|
||||
export interface AgentRevocation {
|
||||
id: string;
|
||||
agentId: string;
|
||||
daisId?: string;
|
||||
revokedBy: string;
|
||||
revocationType: RevocationType;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
keysInvalidated: boolean;
|
||||
walletDisabled: boolean;
|
||||
roomAccessRevoked: boolean;
|
||||
nodePrivilegesRemoved: boolean;
|
||||
assignmentsTerminated: boolean;
|
||||
reversible: boolean;
|
||||
reversedAt?: string;
|
||||
reversedBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INCIDENTS
|
||||
// ============================================================================
|
||||
|
||||
export type IncidentStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
|
||||
export type IncidentPriority = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type EscalationLevel = 'microdao' | 'district' | 'city';
|
||||
export type TargetScopeType = 'city' | 'district' | 'microdao' | 'room' | 'node' | 'agent';
|
||||
|
||||
export const INCIDENT_STATUS_LABELS: Record<IncidentStatus, string> = {
|
||||
open: 'Відкрито',
|
||||
in_progress: 'В роботі',
|
||||
resolved: 'Вирішено',
|
||||
closed: 'Закрито',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_LABELS: Record<IncidentPriority, string> = {
|
||||
low: 'Низький',
|
||||
medium: 'Середній',
|
||||
high: 'Високий',
|
||||
critical: 'Критичний',
|
||||
};
|
||||
|
||||
export const INCIDENT_PRIORITY_COLORS: Record<IncidentPriority, string> = {
|
||||
low: 'slate',
|
||||
medium: 'yellow',
|
||||
high: 'orange',
|
||||
critical: 'red',
|
||||
};
|
||||
|
||||
export const ESCALATION_LABELS: Record<EscalationLevel, string> = {
|
||||
microdao: 'MicroDAO',
|
||||
district: 'District',
|
||||
city: 'City',
|
||||
};
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
status: IncidentStatus;
|
||||
priority: IncidentPriority;
|
||||
assignedToDaisId?: string;
|
||||
escalationLevel: EscalationLevel;
|
||||
title: string;
|
||||
description?: string;
|
||||
resolution?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
resolvedAt?: string;
|
||||
closedAt?: string;
|
||||
}
|
||||
|
||||
export interface IncidentHistory {
|
||||
id: string;
|
||||
incidentId: string;
|
||||
action: 'created' | 'assigned' | 'escalated' | 'resolved' | 'closed' | 'comment';
|
||||
actorDaisId: string;
|
||||
oldValue?: Record<string, unknown>;
|
||||
newValue?: Record<string, unknown>;
|
||||
comment?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUDIT EVENTS
|
||||
// ============================================================================
|
||||
|
||||
export type GovernanceEventType =
|
||||
| 'agent.promoted'
|
||||
| 'agent.demoted'
|
||||
| 'agent.revoked'
|
||||
| 'agent.reinstated'
|
||||
| 'agent.assigned'
|
||||
| 'agent.unassigned'
|
||||
| 'permission.granted'
|
||||
| 'permission.revoked'
|
||||
| 'incident.created'
|
||||
| 'incident.assigned'
|
||||
| 'incident.escalated'
|
||||
| 'incident.resolved'
|
||||
| 'incident.closed'
|
||||
| 'microdao.created'
|
||||
| 'district.created'
|
||||
| 'node.registered'
|
||||
| 'room.created'
|
||||
| 'room.published_to_city';
|
||||
|
||||
export interface GovernanceEvent {
|
||||
id: string;
|
||||
eventType: GovernanceEventType;
|
||||
subject: string;
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
scope: GovernanceScope;
|
||||
payload: Record<string, unknown>;
|
||||
version: string;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API REQUESTS/RESPONSES
|
||||
// ============================================================================
|
||||
|
||||
export interface AgentRolesResponse {
|
||||
level: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
powers: GovernancePower[];
|
||||
assignments: Array<{
|
||||
microdaoId: string;
|
||||
role: string;
|
||||
scope: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PromoteAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
newLevel: AgentGovLevel;
|
||||
scope: GovernanceScope;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RevokeAgentRequest {
|
||||
actorId: string;
|
||||
targetId: string;
|
||||
reason: string;
|
||||
scope: GovernanceScope;
|
||||
revocationType?: RevocationType;
|
||||
}
|
||||
|
||||
export interface CreateIncidentRequest {
|
||||
createdByDaisId: string;
|
||||
targetScopeType: TargetScopeType;
|
||||
targetScopeId: string;
|
||||
priority?: IncidentPriority;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AuditEventFilter {
|
||||
eventType?: GovernanceEventType;
|
||||
actorId?: string;
|
||||
targetId?: string;
|
||||
scope?: GovernanceScope;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
totalEvents: number;
|
||||
eventsByType: Record<string, number>;
|
||||
eventsByDay: Array<{ date: string; count: number }>;
|
||||
topActors: Array<{ actorId: string; count: number }>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GOVERNANCE AGENT (для City Governance Panel)
|
||||
// ============================================================================
|
||||
|
||||
export interface GovernanceAgent {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
govLevel: AgentGovLevel;
|
||||
status: AgentStatus;
|
||||
homeMicrodaoId?: string;
|
||||
homeMicrodaoName?: string;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,6 @@ export * from './microdao';
|
||||
// Node types
|
||||
export * from './nodes';
|
||||
|
||||
// Governance types
|
||||
export * from './governance';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user