refactor(admin): split controls, training, control-provenance, iace/verification pages

Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into
colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator.

- controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm,
  LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into
  _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts
- training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab)
  into _components/ContentTab.tsx
- control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge,
  PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS
  static data into _data/provenance-sections.ts
- iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge,
  VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/

Zero behavior changes; logic relocated verbatim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 22:50:15 +02:00
parent cfd4fc347f
commit 0125199c76
25 changed files with 2447 additions and 3248 deletions

View File

@@ -0,0 +1,56 @@
import { UsageBadge } from './UsageBadge'
interface LicenseInfo {
license_id: string
name: string
terms_url: string | null
commercial_use: string
ai_training_restriction: string | null
tdm_allowed_under_44b: string | null
deletion_required: boolean
notes: string | null
}
export function LicenseMatrix({ licenses, loading }: { licenses: LicenseInfo[]; loading: boolean }) {
return (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
<p className="text-sm text-gray-600 mb-4">Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.license_id} className="hover:bg-gray-50">
<td className="px-3 py-2 border-b">
<div className="font-medium text-gray-900">{lic.license_id}</div>
<div className="text-xs text-gray-500">{lic.name}</div>
</td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.commercial_use} /></td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.ai_training_restriction || 'n/a'} /></td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} /></td>
<td className="px-3 py-2 border-b">
{lic.deletion_required
? <span className="text-red-600 text-xs font-medium">Ja</span>
: <span className="text-green-600 text-xs font-medium">Nein</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,38 @@
export function MarkdownRenderer({ content }: { content: string }) {
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_m, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return <div dangerouslySetInnerHTML={{ __html: html }} />
}

View File

@@ -0,0 +1,57 @@
import { ExternalLink } from 'lucide-react'
import { PermBadge } from './UsageBadge'
interface SourceInfo {
source_id: string
title: string
publisher: string
url: string | null
version_label: string | null
language: string
license_id: string
license_name: string
commercial_use: string
allowed_analysis: boolean
allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean
allowed_ship_in_product: boolean
vault_retention_days: number
vault_access_tier: string
}
export function SourceRegistry({ sources, loading }: { sources: SourceInfo[]; loading: boolean }) {
return (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
<p className="text-sm text-gray-600 mb-4">Alle registrierten Quellen mit ihren Berechtigungen.</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="space-y-3">
{sources.map(src => (
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
<p className="text-xs text-gray-500">{src.publisher} {src.license_name}</p>
</div>
{src.url && (
<a href={src.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800">
<ExternalLink className="w-3 h-3" />
Quelle
</a>
)}
</div>
<div className="flex items-center gap-3 mt-2">
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { CheckCircle2, Lock } from 'lucide-react'
const USAGE_CONFIG: Record<string, { bg: string; label: string }> = {
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
}
export function UsageBadge({ value }: { value: string }) {
const c = USAGE_CONFIG[value] || USAGE_CONFIG.unclear
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
}
export function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
return (
<div className="flex items-center gap-1">
{allowed ? <CheckCircle2 className="w-3 h-3 text-green-500" /> : <Lock className="w-3 h-3 text-red-400" />}
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
</div>
)
}