feat(use-case-controls): Adressat-Achse — out-of-scope advisory + additiver GOV-Tag
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>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
provenanceBadgeClass,
|
||||
severityBadgeClass,
|
||||
splitByTier,
|
||||
addresseeLabel,
|
||||
} from '../_helpers'
|
||||
|
||||
const BACKEND_URL =
|
||||
@@ -13,12 +14,15 @@ const BACKEND_URL =
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
async function getControls(useCase: string): Promise<ControlsResponse | null> {
|
||||
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`,
|
||||
)}/controls?tier=all&limit=200&include_out_of_scope=${oos}`,
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
return res.ok ? ((await res.json()) as ControlsResponse) : null
|
||||
@@ -42,7 +46,23 @@ function ControlsTable({ rows }: { rows: ControlItem[] }) {
|
||||
<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">{c.title}</td>
|
||||
<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>
|
||||
@@ -77,11 +97,15 @@ function ControlsTable({ rows }: { rows: ControlItem[] }) {
|
||||
|
||||
export default async function UseCaseControlsPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ useCase: string }>
|
||||
searchParams: Promise<{ [k: string]: string | string[] | undefined }>
|
||||
}) {
|
||||
const { useCase } = await params
|
||||
const data = await getControls(useCase)
|
||||
const sp = await searchParams
|
||||
const oos = sp.oos === '1'
|
||||
const data = await getControls(useCase, oos)
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
@@ -123,6 +147,25 @@ export default async function UseCaseControlsPage({
|
||||
</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">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
provenanceBadgeClass,
|
||||
splitByTier,
|
||||
severityBadgeClass,
|
||||
addresseeLabel,
|
||||
type UseCaseRow,
|
||||
type ControlItem,
|
||||
} from './_helpers'
|
||||
@@ -17,6 +18,8 @@ const ctrl = (over: Partial<ControlItem>): ControlItem => ({
|
||||
relevant: true,
|
||||
tier: 'core',
|
||||
source_type: 'derived',
|
||||
applicable: true,
|
||||
is_gov: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
@@ -97,6 +100,14 @@ describe('coverage helpers', () => {
|
||||
expect(severityBadgeClass(null)).toContain('gray')
|
||||
})
|
||||
|
||||
it('addressee label maps keys to German labels', () => {
|
||||
expect(addresseeLabel('oeffentliche_stelle')).toBe('Öffentliche Stelle')
|
||||
expect(addresseeLabel('aufsichtsbefugnis')).toBe('Aufsichtsbehörde')
|
||||
expect(addresseeLabel('staat_eu')).toBe('Mitgliedstaat/EU')
|
||||
expect(addresseeLabel(null)).toBe('')
|
||||
expect(addresseeLabel('unbekannt_neu')).toBe('unbekannt_neu')
|
||||
})
|
||||
|
||||
it('splitByTier separates core (relevant) from review', () => {
|
||||
const { core, review } = splitByTier([
|
||||
ctrl({ id: 'a', relevant: true }),
|
||||
|
||||
@@ -94,6 +94,9 @@ export interface ControlItem {
|
||||
relevant: boolean
|
||||
tier: 'core' | 'review'
|
||||
source_type: 'derived' | 'own_library'
|
||||
addressee?: string | null
|
||||
applicable: boolean
|
||||
is_gov: boolean
|
||||
}
|
||||
|
||||
export interface ControlsResponse {
|
||||
@@ -105,6 +108,8 @@ export interface ControlsResponse {
|
||||
total: number
|
||||
core_count: number
|
||||
review_count: number
|
||||
out_of_scope_count: number
|
||||
include_out_of_scope: boolean
|
||||
limit: number
|
||||
offset: number
|
||||
sub_topic: string | null
|
||||
@@ -112,6 +117,22 @@ export interface ControlsResponse {
|
||||
controls: ControlItem[]
|
||||
}
|
||||
|
||||
// Addressee axis: who must fulfil an obligation. out-of-scope (authority power /
|
||||
// member-state-EU / third party / meta) is advisory — hidden by default, never
|
||||
// deleted. oeffentliche_stelle = additive GOV hint (public-sector customer).
|
||||
export const ADDRESSEE_LABELS: Record<string, string> = {
|
||||
unternehmen: 'Unternehmen',
|
||||
oeffentliche_stelle: 'Öffentliche Stelle',
|
||||
aufsichtsbefugnis: 'Aufsichtsbehörde',
|
||||
staat_eu: 'Mitgliedstaat/EU',
|
||||
dritter: 'Dritter',
|
||||
meta: 'Meta',
|
||||
}
|
||||
|
||||
export function addresseeLabel(a?: string | null): string {
|
||||
return a ? ADDRESSEE_LABELS[a] || a : ''
|
||||
}
|
||||
|
||||
// Provenance line: own library vs derived-from-document (with the document, and
|
||||
// article when known). The user wants to see WHERE a derived control came from.
|
||||
export function provenanceLabel(
|
||||
|
||||
Reference in New Issue
Block a user