// Pure helpers for the Coverage page (#74). Kept separate so they are unit-testable // without rendering the server component. export interface UseCaseRow { key: string label: string group: string regulations: string[] verification_methods: string[] mapped_controls: number atom_total: number atom_relevant: number } export interface CorpusDoc { source_regulation: string license_rule: number | null license_tier: string atom_count: number use_case: string | null } export interface LicenseSummaryRow { license_rule: number | null label: string atom_count: number } export interface LicenseCatalogEntry { source_id: string title: string publisher: string | null url: string | null version: string | null license_id: string | null license_name: string | null commercial_use: string | null ship_in_product: boolean | null terms_url: string | null } export interface CorpusOverview { license_summary: LicenseSummaryRow[] documents: CorpusDoc[] license_catalog: LicenseCatalogEntry[] totals: { documents: number; catalog_sources: number } } export const USE_CASE_GROUP_LABELS: Record = { document: 'Dokument-Compliance', security: 'Security', cross_cutting: 'Querschnitt', product: 'Produkt / Sektor', } export function licenseTierBadgeClass(rule: number | null): string { switch (rule) { case 1: return 'bg-green-100 text-green-800' case 2: return 'bg-blue-100 text-blue-800' case 3: return 'bg-amber-100 text-amber-800' default: return 'bg-gray-100 text-gray-700' } } export function commercialBadgeClass(commercial: string | null): string { switch ((commercial || '').toLowerCase()) { case 'allowed': return 'bg-green-100 text-green-800' case 'restricted': return 'bg-amber-100 text-amber-800' case 'prohibited': return 'bg-red-100 text-red-800' default: return 'bg-gray-100 text-gray-700' } } // --- Controls drill-down (#80 Stufe-Flip + Provenance) --- export interface ControlItem { id: string control_id?: string | null title: string objective?: string | null severity?: string | null sub_topic?: string | null canonical_obligation?: string | null source_regulation?: string | null source_article?: string | null relevant: boolean tier: 'core' | 'review' source_type: 'derived' | 'own_library' } export interface ControlsResponse { use_case: string label: string group: string granularity: string tier: string total: number core_count: number review_count: number limit: number offset: number sub_topic: string | null subtopic_counts: Record controls: ControlItem[] } // 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( c: Pick, ): string { if (c.source_type === 'own_library') return 'Eigene Bibliothek' const doc = c.source_regulation?.trim() if (!doc) return 'Abgeleitet' const art = c.source_article?.trim() return art ? `Abgeleitet · ${doc} ${art}` : `Abgeleitet · ${doc}` } export function provenanceBadgeClass(sourceType: string): string { return sourceType === 'own_library' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800' } export function severityBadgeClass(sev: string | null | undefined): string { switch ((sev || '').toLowerCase()) { case 'critical': return 'bg-red-100 text-red-800' case 'high': return 'bg-orange-100 text-orange-800' case 'medium': return 'bg-yellow-100 text-yellow-800' default: return 'bg-gray-100 text-gray-600' } } // Split into the two display tiers: Kern-Pflichten (relevant) and the // 'zur Prüfung' tier (shown but flagged) — never hidden. export function splitByTier(controls: ControlItem[]): { core: ControlItem[] review: ControlItem[] } { const core: ControlItem[] = [] const review: ControlItem[] = [] for (const c of controls) (c.relevant ? core : review).push(c) return { core, review } } export interface UseCaseGroup { group: string label: string rows: UseCaseRow[] } // Group use-cases by their registry group (stable order), each group's rows // sorted by how many relevant obligations it carries (desc). export function groupUseCases(rows: UseCaseRow[]): UseCaseGroup[] { const order = ['document', 'security', 'cross_cutting', 'product'] const by: Record = {} for (const r of rows) { ;(by[r.group] ||= []).push(r) } const groups = order.filter((g) => by[g]?.length) for (const g of Object.keys(by)) { if (!order.includes(g)) groups.push(g) } return groups.map((g) => ({ group: g, label: USE_CASE_GROUP_LABELS[g] || g, rows: [...by[g]].sort((a, b) => b.atom_relevant - a.atom_relevant), })) }