feat(governance): Frontend integration and pages

- Integrate GovernanceRolesBlock into AgentCabinet
- Add ReportButton component for creating incidents
- Add GovernancePage with tabs (City/Audit/Incidents)
- Add /governance route to App.tsx
- Export governance components from index.ts
This commit is contained in:
Apple
2025-11-29 16:06:17 -08:00
parent e233d32ae7
commit bef55b2aa6
5 changed files with 433 additions and 0 deletions

View File

@@ -1,9 +1,14 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { useAgentDashboard } from './hooks/useAgentDashboard';
import { VisibilityCard } from './components/VisibilityCard';
import { AgentChatWidget } from './components/AgentChatWidget';
import { MicroDaoWizard } from './components/MicroDaoWizard';
import { GovernanceLevelBadge } from '../governance/components/GovernanceLevelBadge';
import { governanceApi } from '../../api/governance';
import { GOV_LEVEL_LABELS, POWER_LABELS } from '../../types/governance';
import type { AgentGovLevel, GovernancePower } from '../../types/governance';
export function AgentCabinet() {
const { agentId } = useParams<{ agentId: string }>();
@@ -213,6 +218,9 @@ export function AgentCabinet() {
</div>
</div>
{/* Governance & Roles Block */}
<GovernanceRolesBlock agentId={profile.agent_id} />
{/* Node Info */}
{node && (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
@@ -254,4 +262,106 @@ export function AgentCabinet() {
);
}
// ============================================================================
// Governance Roles Block
// ============================================================================
function GovernanceRolesBlock({ agentId }: { agentId: string }) {
const { data: roles, isLoading, error } = useQuery({
queryKey: ['governance', 'agent', agentId, 'roles'],
queryFn: () => governanceApi.getAgentRoles(agentId),
retry: false,
});
if (isLoading) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🛡 Ролі та Повноваження</h3>
<div className="animate-pulse space-y-3">
<div className="h-8 bg-gray-200 rounded w-1/2" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
</div>
);
}
if (error || !roles) {
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🛡 Ролі та Повноваження</h3>
<p className="text-sm text-gray-500">Governance API недоступний</p>
</div>
);
}
const levelLabel = GOV_LEVEL_LABELS[roles.level as AgentGovLevel] || roles.level;
const powerLabels = roles.powers.map((p: GovernancePower) => POWER_LABELS[p] || p);
return (
<div className="bg-white border border-gray-200 rounded-lg p-6 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🛡 Ролі та Повноваження</h3>
{/* Level Badge */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500">Рівень:</span>
<GovernanceLevelBadge level={roles.level as AgentGovLevel} status={roles.status} />
</div>
{/* Status */}
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-gray-500">Статус:</span>
<span className={`text-sm font-medium px-2 py-1 rounded ${
roles.status === 'active' ? 'bg-green-100 text-green-800' :
roles.status === 'suspended' ? 'bg-yellow-100 text-yellow-800' :
'bg-red-100 text-red-800'
}`}>
{roles.status === 'active' ? '✅ Активний' :
roles.status === 'suspended' ? '⏸️ Призупинено' :
'🚫 Заблоковано'}
</span>
</div>
{/* Powers */}
{roles.powers.length > 0 && (
<div className="mb-4">
<span className="text-sm text-gray-500 block mb-2">Повноваження:</span>
<div className="flex flex-wrap gap-2">
{powerLabels.map((power: string, i: number) => (
<span key={i} className="text-xs px-2 py-1 bg-blue-50 text-blue-700 rounded">
{power}
</span>
))}
</div>
</div>
)}
{/* Assignments */}
{roles.assignments.length > 0 && (
<div className="border-t border-gray-100 pt-4 mt-4">
<span className="text-sm text-gray-500 block mb-2">Призначення:</span>
<div className="space-y-2">
{roles.assignments.map((a: { microdaoId: string; role: string; scope: string }, i: number) => (
<div key={i} className="flex items-center justify-between text-sm">
<span className="text-gray-600">{a.microdaoId}</span>
<span className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">
{a.role}
</span>
</div>
))}
</div>
</div>
)}
{/* Actions (placeholder for governance actions) */}
<div className="border-t border-gray-100 pt-4 mt-4">
<a
href={`/governance/agent/${agentId}`}
className="text-blue-600 hover:text-blue-700 text-sm font-medium flex items-center gap-1"
>
Governance Actions
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
/**
* Report Button
* Universal button to create incidents from any context
*/
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { incidentsApi } from '../../../api/incidents';
import type { IncidentPriority, TargetScopeType } from '../../../types/governance';
import { INCIDENT_PRIORITY_LABELS } from '../../../types/governance';
interface ReportButtonProps {
targetScopeType: TargetScopeType;
targetScopeId: string;
actorDaisId?: string;
variant?: 'icon' | 'text' | 'full';
className?: string;
defaultTitle?: string;
}
export const ReportButton: React.FC<ReportButtonProps> = ({
targetScopeType,
targetScopeId,
actorDaisId,
variant = 'icon',
className = '',
defaultTitle = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [form, setForm] = useState({
title: defaultTitle,
description: '',
priority: 'medium' as IncidentPriority,
});
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: () => {
if (!actorDaisId) {
throw new Error('Actor DAIS ID is required');
}
return incidentsApi.createIncident({
createdByDaisId: actorDaisId,
targetScopeType,
targetScopeId,
priority: form.priority,
title: form.title,
description: form.description || undefined,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['incidents'] });
setIsOpen(false);
setForm({ title: defaultTitle, description: '', priority: 'medium' });
},
});
if (!actorDaisId) {
return null;
}
const renderButton = () => {
switch (variant) {
case 'icon':
return (
<button
onClick={() => setIsOpen(true)}
className={`p-2 text-gray-400 hover:text-red-500 rounded-full hover:bg-red-50 transition-colors ${className}`}
title="Поскаржитись"
>
</button>
);
case 'text':
return (
<button
onClick={() => setIsOpen(true)}
className={`text-sm text-gray-500 hover:text-red-600 transition-colors ${className}`}
>
Поскаржитись
</button>
);
case 'full':
return (
<button
onClick={() => setIsOpen(true)}
className={`px-4 py-2 bg-red-50 hover:bg-red-100 text-red-700 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors ${className}`}
>
Повідомити про проблему
</button>
);
}
};
return (
<>
{renderButton()}
{isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Повідомити про проблему</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="p-6 space-y-4">
<div className="bg-gray-50 rounded-lg p-3 text-sm">
<div className="text-gray-500">Об'єкт скарги:</div>
<div className="font-medium text-gray-900">
{targetScopeType}: {targetScopeId}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Заголовок *
</label>
<input
type="text"
value={form.title}
onChange={(e) => setForm(prev => ({ ...prev, title: e.target.value }))}
placeholder="Коротко опишіть проблему..."
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Опис
</label>
<textarea
value={form.description}
onChange={(e) => setForm(prev => ({ ...prev, description: e.target.value }))}
placeholder="Детальний опис проблеми..."
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пріоритет
</label>
<select
value={form.priority}
onChange={(e) => setForm(prev => ({ ...prev, priority: e.target.value as IncidentPriority }))}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
{Object.entries(INCIDENT_PRIORITY_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
{createMutation.error && (
<div className="bg-red-50 text-red-700 text-sm rounded-lg p-3">
{(createMutation.error as Error).message}
</div>
)}
</div>
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 bg-gray-50 rounded-b-xl">
<button
onClick={() => setIsOpen(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg"
>
Скасувати
</button>
<button
onClick={() => createMutation.mutate()}
disabled={!form.title.trim() || createMutation.isPending}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium disabled:opacity-50"
>
{createMutation.isPending ? 'Надсилання...' : 'Надіслати'}
</button>
</div>
</div>
</div>
)}
</>
);
};
export default ReportButton;

View File

@@ -0,0 +1,11 @@
/**
* Governance Feature Exports
*/
// Components
export { GovernanceLevelBadge } from './components/GovernanceLevelBadge';
export { CityGovernancePanel } from './components/CityGovernancePanel';
export { AuditDashboard } from './components/AuditDashboard';
export { IncidentsList } from './components/IncidentsList';
export { ReportButton } from './components/ReportButton';