0a6e57ac02
2-Pass-Haiku-Klassifikation (konservativ + Re-Confirm jeder Nicht-unternehmen- Einstufung) der Review-Tier-Atome: wer muss die Pflicht erfuellen? - Migration 155: atom_classification.addressee (unternehmen/oeffentliche_stelle/ aufsichtsbefugnis/staat_eu/dritter/meta), additiv, kein CHECK. [migration-approved] - Service: addressee + applicable + is_gov pro Control; include_out_of_scope-Param (Default false -> out-of-scope advisory ausgeblendet, NIE geloescht); out_of_scope_count. Pure Helper addressee_applicable/addressee_is_gov (+ Tests). - Route: optionaler include_out_of_scope-Query (contract-safe, additiv). - Frontend: GOV-Chip (additiv) + "kein Kunden-Pruefaspekt"-Chip + 1-Klick-Toggle zum Einblenden der out-of-scope-Atome. Daten: 40.859 Adressat-Tags auf macmini geladen (81% applicable, 19% advisory, 3.146 GOV). Konservativ: NULL/Unklar = applicable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
6.8 KiB
TypeScript
209 lines
6.8 KiB
TypeScript
import Link from 'next/link'
|
|
import {
|
|
type ControlsResponse,
|
|
type ControlItem,
|
|
provenanceLabel,
|
|
provenanceBadgeClass,
|
|
severityBadgeClass,
|
|
splitByTier,
|
|
addresseeLabel,
|
|
} from '../_helpers'
|
|
|
|
const BACKEND_URL =
|
|
process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
async function getControls(
|
|
useCase: string,
|
|
oos: boolean,
|
|
): Promise<ControlsResponse | null> {
|
|
try {
|
|
const res = await fetch(
|
|
`${BACKEND_URL}/api/compliance/v1/controls/use-cases/${encodeURIComponent(
|
|
useCase,
|
|
)}/controls?tier=all&limit=200&include_out_of_scope=${oos}`,
|
|
{ cache: 'no-store' },
|
|
)
|
|
return res.ok ? ((await res.json()) as ControlsResponse) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function ControlsTable({ rows }: { rows: ControlItem[] }) {
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-gray-200">
|
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
|
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
|
|
<tr>
|
|
<th className="px-4 py-2">Prüfaspekt</th>
|
|
<th className="px-4 py-2">Sub-Thema</th>
|
|
<th className="px-4 py-2">Schwere</th>
|
|
<th className="px-4 py-2">Herkunft</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 bg-white">
|
|
{rows.map((c) => (
|
|
<tr key={c.id}>
|
|
<td className="px-4 py-2 text-gray-900">
|
|
<div>{c.title}</div>
|
|
{c.is_gov || !c.applicable ? (
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|
{c.is_gov ? (
|
|
<span className="rounded bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-800">
|
|
GOV · Öffentliche Stelle
|
|
</span>
|
|
) : null}
|
|
{!c.applicable ? (
|
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-800">
|
|
kein Kunden-Prüfaspekt · {addresseeLabel(c.addressee)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td className="px-4 py-2 text-xs text-gray-500">
|
|
{c.sub_topic || '—'}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
{c.severity ? (
|
|
<span
|
|
className={`rounded px-2 py-0.5 text-xs font-medium ${severityBadgeClass(
|
|
c.severity,
|
|
)}`}
|
|
>
|
|
{c.severity}
|
|
</span>
|
|
) : null}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<span
|
|
className={`rounded px-2 py-0.5 text-xs font-medium ${provenanceBadgeClass(
|
|
c.source_type,
|
|
)}`}
|
|
title={c.source_article || undefined}
|
|
>
|
|
{provenanceLabel(c)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default async function UseCaseControlsPage({
|
|
params,
|
|
searchParams,
|
|
}: {
|
|
params: Promise<{ useCase: string }>
|
|
searchParams: Promise<{ [k: string]: string | string[] | undefined }>
|
|
}) {
|
|
const { useCase } = await params
|
|
const sp = await searchParams
|
|
const oos = sp.oos === '1'
|
|
const data = await getControls(useCase, oos)
|
|
|
|
if (!data) {
|
|
return (
|
|
<div className="mx-auto max-w-7xl space-y-4 p-6">
|
|
<Link
|
|
href="/sdk/coverage"
|
|
className="text-sm text-purple-600 hover:underline"
|
|
>
|
|
← Abdeckung
|
|
</Link>
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-6 text-amber-800">
|
|
Keine atom-grain-Daten für „{useCase}" gefunden.
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { core, review } = splitByTier(data.controls)
|
|
|
|
return (
|
|
<div className="mx-auto max-w-7xl space-y-8 p-6">
|
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
<Link href="/sdk/coverage" className="hover:text-purple-600">
|
|
Abdeckung
|
|
</Link>
|
|
<span>/</span>
|
|
<span className="text-gray-900">{data.label}</span>
|
|
</div>
|
|
|
|
<header className="space-y-1">
|
|
<h1 className="text-2xl font-bold text-gray-900">{data.label}</h1>
|
|
<p className="text-sm text-gray-600">
|
|
<span className="font-semibold text-gray-900">
|
|
{data.core_count.toLocaleString('de-DE')}
|
|
</span>{' '}
|
|
Kern-Pflichten ·{' '}
|
|
<span className="font-semibold text-gray-900">
|
|
{data.review_count.toLocaleString('de-DE')}
|
|
</span>{' '}
|
|
zur fachlichen Prüfung
|
|
</p>
|
|
{data.out_of_scope_count > 0 ? (
|
|
<p className="text-xs">
|
|
{data.include_out_of_scope ? (
|
|
<Link
|
|
href={`/sdk/coverage/${useCase}`}
|
|
className="text-purple-600 hover:underline"
|
|
>
|
|
← Nur Kunden-Prüfaspekte ({data.out_of_scope_count.toLocaleString('de-DE')} Behörde/Mitgliedstaat/Dritter ausblenden)
|
|
</Link>
|
|
) : (
|
|
<Link
|
|
href={`/sdk/coverage/${useCase}?oos=1`}
|
|
className="text-amber-700 hover:underline"
|
|
>
|
|
+ {data.out_of_scope_count.toLocaleString('de-DE')} ausgeblendet (kein Kunden-Prüfaspekt: Behörde/Mitgliedstaat/Dritter) — einblenden
|
|
</Link>
|
|
)}
|
|
</p>
|
|
) : null}
|
|
</header>
|
|
|
|
<section className="space-y-2">
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
Kern-Pflichten ({core.length})
|
|
</h2>
|
|
{core.length ? (
|
|
<ControlsTable rows={core} />
|
|
) : (
|
|
<p className="text-sm text-gray-500">—</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="space-y-2">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
Zur fachlichen Prüfung ({review.length})
|
|
</h2>
|
|
<p className="text-xs text-gray-500">
|
|
Breiter gefasste Aspekte — bewusst eher zu viel als zu wenig. Fachlich
|
|
prüfen; nicht zutreffende lassen sich (künftig) als unanwendbar
|
|
markieren.
|
|
</p>
|
|
</div>
|
|
{review.length ? (
|
|
<ControlsTable rows={review} />
|
|
) : (
|
|
<p className="text-sm text-gray-500">—</p>
|
|
)}
|
|
</section>
|
|
|
|
{data.total > data.controls.length ? (
|
|
<p className="text-xs text-gray-400">
|
|
Angezeigt: erste {data.controls.length.toLocaleString('de-DE')} von{' '}
|
|
{data.total.toLocaleString('de-DE')} — Sub-Thema-Filter folgt.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|