Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 27s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
653 lines
26 KiB
TypeScript
653 lines
26 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface Portfolio {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
status: 'DRAFT' | 'ACTIVE' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
|
|
department: string
|
|
business_unit: string
|
|
owner: string
|
|
owner_email: string
|
|
total_assessments: number
|
|
total_roadmaps: number
|
|
total_workshops: number
|
|
avg_risk_score: number
|
|
high_risk_count: number
|
|
compliance_score: number
|
|
auto_update_metrics: boolean
|
|
require_approval: boolean
|
|
created_at: string
|
|
updated_at: string
|
|
approved_at: string | null
|
|
approved_by: string | null
|
|
}
|
|
|
|
interface PortfolioItem {
|
|
id: string
|
|
portfolio_id: string
|
|
item_type: 'ASSESSMENT' | 'ROADMAP' | 'WORKSHOP' | 'DOCUMENT'
|
|
item_id: string
|
|
title: string
|
|
status: string
|
|
risk_level: string
|
|
risk_score: number
|
|
feasibility: string
|
|
sort_order: number
|
|
tags: string[]
|
|
notes: string
|
|
created_at: string
|
|
}
|
|
|
|
interface PortfolioStats {
|
|
total_items: number
|
|
items_by_type: Record<string, number>
|
|
risk_distribution: Record<string, number>
|
|
avg_risk_score: number
|
|
compliance_score: number
|
|
}
|
|
|
|
interface ActivityEntry {
|
|
timestamp: string
|
|
action: string
|
|
item_type: string
|
|
item_id: string
|
|
item_title: string
|
|
user_id: string
|
|
}
|
|
|
|
interface CompareResult {
|
|
portfolios: Portfolio[]
|
|
risk_scores: Record<string, number>
|
|
compliance_scores: Record<string, number>
|
|
item_counts: Record<string, number>
|
|
common_items: string[]
|
|
unique_items: Record<string, string[]>
|
|
}
|
|
|
|
// =============================================================================
|
|
// API
|
|
// =============================================================================
|
|
|
|
const API_BASE = '/api/sdk/v1/portfolio'
|
|
|
|
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new Error(err.error || err.message || `HTTP ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENTS
|
|
// =============================================================================
|
|
|
|
const statusColors: Record<string, string> = {
|
|
DRAFT: 'bg-gray-100 text-gray-700',
|
|
ACTIVE: 'bg-green-100 text-green-700',
|
|
REVIEW: 'bg-yellow-100 text-yellow-700',
|
|
APPROVED: 'bg-purple-100 text-purple-700',
|
|
ARCHIVED: 'bg-red-100 text-red-700',
|
|
}
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
DRAFT: 'Entwurf',
|
|
ACTIVE: 'Aktiv',
|
|
REVIEW: 'In Pruefung',
|
|
APPROVED: 'Genehmigt',
|
|
ARCHIVED: 'Archiviert',
|
|
}
|
|
|
|
function PortfolioCard({ portfolio, onSelect, onDelete }: {
|
|
portfolio: Portfolio
|
|
onSelect: (p: Portfolio) => void
|
|
onDelete: (id: string) => void
|
|
}) {
|
|
const totalItems = portfolio.total_assessments + portfolio.total_roadmaps + portfolio.total_workshops
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
|
|
onClick={() => onSelect(portfolio)}>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-semibold text-gray-900 truncate">{portfolio.name}</h4>
|
|
{portfolio.department && <span className="text-xs text-gray-500">{portfolio.department}</span>}
|
|
</div>
|
|
<span className={`px-2 py-1 text-xs rounded-full ml-2 ${statusColors[portfolio.status] || 'bg-gray-100 text-gray-700'}`}>
|
|
{statusLabels[portfolio.status] || portfolio.status}
|
|
</span>
|
|
</div>
|
|
|
|
{portfolio.description && (
|
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{portfolio.description}</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
|
<div className="bg-gray-50 rounded-lg p-2 text-center">
|
|
<div className="text-lg font-bold text-purple-600">{portfolio.compliance_score}%</div>
|
|
<div className="text-xs text-gray-500">Compliance</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-2 text-center">
|
|
<div className="text-lg font-bold text-gray-900">{portfolio.avg_risk_score.toFixed(1)}</div>
|
|
<div className="text-xs text-gray-500">Risiko</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-2 text-center">
|
|
<div className="text-lg font-bold text-gray-900">{totalItems}</div>
|
|
<div className="text-xs text-gray-500">Items</div>
|
|
</div>
|
|
</div>
|
|
|
|
{portfolio.high_risk_count > 0 && (
|
|
<div className="flex items-center gap-1 text-xs text-red-600 mb-3">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
|
</svg>
|
|
{portfolio.high_risk_count} Hoch-Risiko
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-gray-400">{portfolio.owner || 'Kein Owner'}</span>
|
|
<button onClick={(e) => { e.stopPropagation(); onDelete(portfolio.id) }}
|
|
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded">
|
|
Loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreatePortfolioModal({ onClose, onCreated }: {
|
|
onClose: () => void
|
|
onCreated: () => void
|
|
}) {
|
|
const [name, setName] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [department, setDepartment] = useState('')
|
|
const [owner, setOwner] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const handleCreate = async () => {
|
|
if (!name.trim()) return
|
|
setSaving(true)
|
|
try {
|
|
await api('', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
department: department.trim(),
|
|
owner: owner.trim(),
|
|
}),
|
|
})
|
|
onCreated()
|
|
} catch (err) {
|
|
console.error('Create portfolio error:', err)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Neues Portfolio</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
placeholder="z.B. KI-Portfolio Q1 2026" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
|
<textarea value={description} onChange={e => setDescription(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
rows={3} />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Abteilung</label>
|
|
<input type="text" value={department} onChange={e => setDepartment(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
|
<input type="text" value={owner} onChange={e => setOwner(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
|
<button onClick={handleCreate} disabled={!name.trim() || saving}
|
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
|
{saving ? 'Erstelle...' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PortfolioDetailView({ portfolio, onBack, onRefresh }: {
|
|
portfolio: Portfolio
|
|
onBack: () => void
|
|
onRefresh: () => void
|
|
}) {
|
|
const [items, setItems] = useState<PortfolioItem[]>([])
|
|
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
|
const [stats, setStats] = useState<PortfolioStats | null>(null)
|
|
const [activeTab, setActiveTab] = useState<'items' | 'activity' | 'compare'>('items')
|
|
const [loading, setLoading] = useState(true)
|
|
const [compareIds, setCompareIds] = useState('')
|
|
const [compareResult, setCompareResult] = useState<CompareResult | null>(null)
|
|
|
|
const loadDetails = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [i, a, s] = await Promise.all([
|
|
api<PortfolioItem[]>(`/${portfolio.id}/items`).catch(() => []),
|
|
api<ActivityEntry[]>(`/${portfolio.id}/activity`).catch(() => []),
|
|
api<PortfolioStats>(`/${portfolio.id}/stats`).catch(() => null),
|
|
])
|
|
setItems(Array.isArray(i) ? i : [])
|
|
setActivity(Array.isArray(a) ? a : [])
|
|
setStats(s)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [portfolio.id])
|
|
|
|
useEffect(() => { loadDetails() }, [loadDetails])
|
|
|
|
const handleSubmitReview = async () => {
|
|
try {
|
|
await api(`/${portfolio.id}/submit-review`, { method: 'POST' })
|
|
onRefresh()
|
|
} catch (err) {
|
|
console.error('Submit review error:', err)
|
|
}
|
|
}
|
|
|
|
const handleApprove = async () => {
|
|
try {
|
|
await api(`/${portfolio.id}/approve`, { method: 'POST' })
|
|
onRefresh()
|
|
} catch (err) {
|
|
console.error('Approve error:', err)
|
|
}
|
|
}
|
|
|
|
const handleRecalculate = async () => {
|
|
try {
|
|
await api(`/${portfolio.id}/recalculate`, { method: 'POST' })
|
|
loadDetails()
|
|
onRefresh()
|
|
} catch (err) {
|
|
console.error('Recalculate error:', err)
|
|
}
|
|
}
|
|
|
|
const handleCompare = async () => {
|
|
const ids = compareIds.split(',').map(s => s.trim()).filter(Boolean)
|
|
if (ids.length < 1) return
|
|
try {
|
|
const result = await api<CompareResult>('/compare', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ portfolio_ids: [portfolio.id, ...ids] }),
|
|
})
|
|
setCompareResult(result)
|
|
} catch (err) {
|
|
console.error('Compare error:', err)
|
|
}
|
|
}
|
|
|
|
const handleRemoveItem = async (itemId: string) => {
|
|
try {
|
|
await api(`/${portfolio.id}/items/${itemId}`, { method: 'DELETE' })
|
|
setItems(prev => prev.filter(i => i.id !== itemId))
|
|
} catch (err) {
|
|
console.error('Remove item error:', err)
|
|
}
|
|
}
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
ASSESSMENT: 'Assessment', ROADMAP: 'Roadmap', WORKSHOP: 'Workshop', DOCUMENT: 'Dokument',
|
|
}
|
|
const typeColors: Record<string, string> = {
|
|
ASSESSMENT: 'bg-blue-100 text-blue-700', ROADMAP: 'bg-green-100 text-green-700',
|
|
WORKSHOP: 'bg-purple-100 text-purple-700', DOCUMENT: 'bg-orange-100 text-orange-700',
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
|
|
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900">{portfolio.name}</h2>
|
|
<p className="text-sm text-gray-500 mt-1">{portfolio.description}</p>
|
|
</div>
|
|
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[portfolio.status]}`}>
|
|
{statusLabels[portfolio.status]}
|
|
</span>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-purple-600">{stats.compliance_score}%</div>
|
|
<div className="text-xs text-gray-500">Compliance</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.avg_risk_score.toFixed(1)}</div>
|
|
<div className="text-xs text-gray-500">Risiko-Score</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.total_items}</div>
|
|
<div className="text-xs text-gray-500">Items</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-red-600">{portfolio.high_risk_count}</div>
|
|
<div className="text-xs text-gray-500">Hoch-Risiko</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
{portfolio.status === 'ACTIVE' && (
|
|
<button onClick={handleSubmitReview} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">
|
|
Zur Pruefung einreichen
|
|
</button>
|
|
)}
|
|
{portfolio.status === 'REVIEW' && (
|
|
<button onClick={handleApprove} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
|
Genehmigen
|
|
</button>
|
|
)}
|
|
<button onClick={handleRecalculate} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">
|
|
Metriken neu berechnen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
|
|
{(['items', 'activity', 'compare'] as const).map(tab => (
|
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
|
|
{tab === 'items' ? `Items (${items.length})` : tab === 'activity' ? 'Aktivitaet' : 'Vergleich'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-8 text-gray-500">Laden...</div>
|
|
) : (
|
|
<>
|
|
{activeTab === 'items' && (
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Titel</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Risiko</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{items.map(item => (
|
|
<tr key={item.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-gray-900">{item.title}</div>
|
|
{item.notes && <div className="text-xs text-gray-500 truncate max-w-xs">{item.notes}</div>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${typeColors[item.item_type] || 'bg-gray-100 text-gray-700'}`}>
|
|
{typeLabels[item.item_type] || item.item_type}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{item.status}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-sm font-medium ${
|
|
item.risk_score >= 7 ? 'text-red-600' : item.risk_score >= 4 ? 'text-yellow-600' : 'text-green-600'
|
|
}`}>{item.risk_score.toFixed(1)}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<button onClick={() => handleRemoveItem(item.id)}
|
|
className="text-xs text-red-500 hover:text-red-700">Entfernen</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{items.length === 0 && (
|
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Items</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'activity' && (
|
|
<div className="space-y-3">
|
|
{activity.map((a, i) => (
|
|
<div key={i} className="bg-white rounded-lg border border-gray-200 p-4 flex items-center gap-4">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs ${
|
|
a.action === 'added' ? 'bg-green-500' : a.action === 'removed' ? 'bg-red-500' : 'bg-blue-500'
|
|
}`}>
|
|
{a.action === 'added' ? '+' : a.action === 'removed' ? '-' : '~'}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-sm text-gray-900">
|
|
<span className="font-medium">{a.item_title || a.item_id}</span> {a.action}
|
|
</div>
|
|
<div className="text-xs text-gray-500">{a.item_type}</div>
|
|
</div>
|
|
<div className="text-xs text-gray-400">{new Date(a.timestamp).toLocaleString('de-DE')}</div>
|
|
</div>
|
|
))}
|
|
{activity.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">Keine Aktivitaet</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'compare' && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Portfolio-Vergleich</h3>
|
|
<div className="flex gap-3 mb-4">
|
|
<input
|
|
type="text" value={compareIds} onChange={e => setCompareIds(e.target.value)}
|
|
className="flex-1 px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500"
|
|
placeholder="Portfolio-IDs (kommagetrennt)"
|
|
/>
|
|
<button onClick={handleCompare}
|
|
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700">
|
|
Vergleichen
|
|
</button>
|
|
</div>
|
|
{compareResult && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500">Portfolio</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Risiko-Score</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Compliance</th>
|
|
<th className="px-4 py-3 text-center text-xs font-medium text-gray-500">Items</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{compareResult.portfolios?.map(p => (
|
|
<tr key={p.id}>
|
|
<td className="px-4 py-3 font-medium text-gray-900">{p.name}</td>
|
|
<td className="px-4 py-3 text-center">{compareResult.risk_scores?.[p.id]?.toFixed(1) ?? '-'}</td>
|
|
<td className="px-4 py-3 text-center">{compareResult.compliance_scores?.[p.id] ?? '-'}%</td>
|
|
<td className="px-4 py-3 text-center">{compareResult.item_counts?.[p.id] ?? '-'}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{compareResult.common_items?.length > 0 && (
|
|
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
|
<div className="text-xs font-medium text-gray-500 mb-1">Gemeinsame Items: {compareResult.common_items.length}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function PortfolioPage() {
|
|
const [portfolios, setPortfolios] = useState<Portfolio[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [selectedPortfolio, setSelectedPortfolio] = useState<Portfolio | null>(null)
|
|
const [filter, setFilter] = useState<string>('all')
|
|
|
|
const loadPortfolios = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await api<Portfolio[] | { portfolios: Portfolio[] }>('')
|
|
const list = Array.isArray(data) ? data : (data.portfolios || [])
|
|
setPortfolios(list)
|
|
} catch (err) {
|
|
console.error('Load portfolios error:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadPortfolios() }, [loadPortfolios])
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Portfolio wirklich loeschen?')) return
|
|
try {
|
|
await api(`/${id}`, { method: 'DELETE' })
|
|
setPortfolios(prev => prev.filter(p => p.id !== id))
|
|
} catch (err) {
|
|
console.error('Delete error:', err)
|
|
}
|
|
}
|
|
|
|
const filteredPortfolios = filter === 'all'
|
|
? portfolios
|
|
: portfolios.filter(p => p.status === filter)
|
|
|
|
if (selectedPortfolio) {
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto">
|
|
<PortfolioDetailView
|
|
portfolio={selectedPortfolio}
|
|
onBack={() => { setSelectedPortfolio(null); loadPortfolios() }}
|
|
onRefresh={() => {
|
|
loadPortfolios().then(() => {
|
|
const updated = portfolios.find(p => p.id === selectedPortfolio.id)
|
|
if (updated) setSelectedPortfolio(updated)
|
|
})
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">KI-Portfolios</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Verwaltung und Vergleich von Compliance-Portfolios
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Neues Portfolio
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
|
{[
|
|
{ label: 'Gesamt', value: portfolios.length, color: 'text-gray-900' },
|
|
{ label: 'Aktiv', value: portfolios.filter(p => p.status === 'ACTIVE').length, color: 'text-green-600' },
|
|
{ label: 'In Pruefung', value: portfolios.filter(p => p.status === 'REVIEW').length, color: 'text-yellow-600' },
|
|
{ label: 'Genehmigt', value: portfolios.filter(p => p.status === 'APPROVED').length, color: 'text-purple-600' },
|
|
].map(stat => (
|
|
<div key={stat.label} className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
|
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
|
<div className="text-xs text-gray-500">{stat.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<div className="flex gap-2 mb-6">
|
|
{['all', 'DRAFT', 'ACTIVE', 'REVIEW', 'APPROVED'].map(f => (
|
|
<button key={f} onClick={() => setFilter(f)}
|
|
className={`px-3 py-1.5 text-sm rounded-lg ${filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
|
{f === 'all' ? 'Alle' : statusLabels[f] || f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-12 text-gray-500">Portfolios werden geladen...</div>
|
|
) : filteredPortfolios.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="text-gray-400 mb-2">
|
|
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-500">Keine Portfolios gefunden</p>
|
|
<button onClick={() => setShowCreate(true)} className="mt-3 text-sm text-purple-600 hover:text-purple-700">
|
|
Erstes Portfolio erstellen
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredPortfolios.map(p => (
|
|
<PortfolioCard key={p.id} portfolio={p} onSelect={setSelectedPortfolio} onDelete={handleDelete} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CreatePortfolioModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); loadPortfolios() }} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|