feat(ai-act-kompass): anti-AI print design and company personalization

- Full redesign: paper tone, serif typography, hairlines, rectangular
  stamp badges, mono labels — technical print look instead of SaaS style,
  no gradients/shadows/emoji
- Company profile settings (name, address, contact person, email, phone,
  website, logo upload as local data URL) persisted in localStorage and
  included in JSON backup (schema v2, v1 imports still supported)
- Print/PDF output via letterhead print view (window.print): logo,
  company name and contact data in header, contact line in footer;
  applies to risk report, action plan, Annex IV skeleton and inventory
- Company block also embedded in generated Markdown headers
- Minimal auditable Markdown renderer (no external parser), HTML-escaped
- 8 new tests (32 total)

Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
This commit is contained in:
Claude 2026-07-04 08:53:37 +00:00
parent fb2da0db00
commit 06574c5cfd
No known key found for this signature in database
12 changed files with 792 additions and 150 deletions

View File

@ -3,7 +3,7 @@ import { CONTENT_PACK } from '../content/pack';
import { classify } from './classify'; import { classify } from './classify';
import { actionPlan, annexIVSkeleton, inventoryReport, riskReport } from './documents'; import { actionPlan, annexIVSkeleton, inventoryReport, riskReport } from './documents';
import { deriveObligations } from './obligations'; import { deriveObligations } from './obligations';
import type { AISystem, AssessmentAnswers } from './types'; import type { AISystem, AssessmentAnswers, CompanyProfile } from './types';
const answers: AssessmentAnswers = { const answers: AssessmentAnswers = {
isAISystem: true, isAISystem: true,
@ -63,4 +63,27 @@ describe('documents', () => {
expect(md).toContain('| System |'); expect(md).toContain('| System |');
expect(md).toContain('Registrierte KI-Systeme: **1**'); expect(md).toContain('Registrierte KI-Systeme: **1**');
}); });
it('übernimmt das Firmenprofil in den Berichtskopf', () => {
const profile: CompanyProfile = {
name: 'Muster GmbH',
street: 'Musterstraße 1',
zipCity: '60311 Frankfurt',
contactPerson: 'Max Mustermann',
email: 'compliance@muster.de',
phone: '+49 69 000000',
website: 'www.muster.de',
logoDataUrl: '',
};
const md = riskReport(system, CONTENT_PACK, TODAY, profile);
expect(md).toContain('| **Unternehmen** | Muster GmbH |');
expect(md).toContain('compliance@muster.de');
expect(md).toContain('| **Ansprechpartner** | Max Mustermann |');
expect(inventoryReport([system], CONTENT_PACK, TODAY, profile)).toContain('Muster GmbH · Stand');
});
it('lässt den Berichtskopf ohne Profil unverändert', () => {
const md = riskReport(system, CONTENT_PACK, TODAY);
expect(md).not.toContain('**Unternehmen**');
});
}); });

View File

@ -1,4 +1,4 @@
import type { AISystem, ContentPack, Obligation } from './types'; import type { AISystem, CompanyProfile, ContentPack, Obligation } from './types';
const RISK_LABELS: Record<string, string> = { const RISK_LABELS: Record<string, string> = {
'out-of-scope': 'Nicht anwendbar (keine KI i. S. d. Art. 3 Nr. 1)', 'out-of-scope': 'Nicht anwendbar (keine KI i. S. d. Art. 3 Nr. 1)',
@ -31,12 +31,32 @@ function formatDate(iso: string): string {
return d && m && y ? `${d}.${m}.${y}` : iso; return d && m && y ? `${d}.${m}.${y}` : iso;
} }
function header(title: string, system: AISystem, pack: ContentPack, today: string): string { /** Kontaktzeile aus dem Firmenprofil (leer, wenn kein Profil gepflegt). */
function companyRows(profile: CompanyProfile | undefined): readonly string[] {
if (!profile || profile.name.trim() === '') return [];
const contact = [profile.street, profile.zipCity, profile.email, profile.phone, profile.website]
.filter((p) => p.trim() !== '')
.join(' · ');
return [
`| **Unternehmen** | ${profile.name} |`,
...(contact ? [`| **Kontakt** | ${contact} |`] : []),
...(profile.contactPerson.trim() ? [`| **Ansprechpartner** | ${profile.contactPerson} |`] : []),
];
}
function header(
title: string,
system: AISystem,
pack: ContentPack,
today: string,
profile?: CompanyProfile,
): string {
return [ return [
`# ${title}`, `# ${title}`,
'', '',
`| | |`, `| | |`,
`|---|---|`, `|---|---|`,
...companyRows(profile),
`| **KI-System** | ${system.name} |`, `| **KI-System** | ${system.name} |`,
`| **Zweckbestimmung** | ${system.purpose || '—'} |`, `| **Zweckbestimmung** | ${system.purpose || '—'} |`,
`| **Hersteller/Anbieter** | ${system.vendor || '—'} |`, `| **Hersteller/Anbieter** | ${system.vendor || '—'} |`,
@ -57,9 +77,15 @@ function header(title: string, system: AISystem, pack: ContentPack, today: strin
* @param system - Bewertetes KI-System * @param system - Bewertetes KI-System
* @param pack - Regulatorischer Content-Pack * @param pack - Regulatorischer Content-Pack
* @param today - Stichtag im ISO-Format * @param today - Stichtag im ISO-Format
* @param profile - Firmenprofil für den Berichtskopf (optional)
*/ */
export function riskReport(system: AISystem, pack: ContentPack, today: string): string { export function riskReport(
const lines = [header('Risikoklassifizierung nach EU AI Act', system, pack, today)]; system: AISystem,
pack: ContentPack,
today: string,
profile?: CompanyProfile,
): string {
const lines = [header('Risikoklassifizierung nach EU AI Act', system, pack, today, profile)];
lines.push('## Ergebnis', ''); lines.push('## Ergebnis', '');
lines.push(`**${RISK_LABELS[system.classification.riskClass] ?? system.classification.riskClass}**`, ''); lines.push(`**${RISK_LABELS[system.classification.riskClass] ?? system.classification.riskClass}**`, '');
@ -92,14 +118,16 @@ export function riskReport(system: AISystem, pack: ContentPack, today: string):
* @param obligations - Anwendbare Pflichten * @param obligations - Anwendbare Pflichten
* @param pack - Regulatorischer Content-Pack * @param pack - Regulatorischer Content-Pack
* @param today - Stichtag im ISO-Format * @param today - Stichtag im ISO-Format
* @param profile - Firmenprofil für den Berichtskopf (optional)
*/ */
export function actionPlan( export function actionPlan(
system: AISystem, system: AISystem,
obligations: readonly Obligation[], obligations: readonly Obligation[],
pack: ContentPack, pack: ContentPack,
today: string, today: string,
profile?: CompanyProfile,
): string { ): string {
const lines = [header('Maßnahmenplan EU AI Act', system, pack, today)]; const lines = [header('Maßnahmenplan EU AI Act', system, pack, today, profile)];
if (obligations.length === 0) { if (obligations.length === 0) {
lines.push('Für dieses System bestehen keine umsetzbaren Pflichten (verboten oder außerhalb des Anwendungsbereichs).'); lines.push('Für dieses System bestehen keine umsetzbaren Pflichten (verboten oder außerhalb des Anwendungsbereichs).');
@ -130,10 +158,18 @@ export function actionPlan(
* @param system - Bewertetes KI-System * @param system - Bewertetes KI-System
* @param pack - Regulatorischer Content-Pack * @param pack - Regulatorischer Content-Pack
* @param today - Stichtag im ISO-Format * @param today - Stichtag im ISO-Format
* @param profile - Firmenprofil für den Berichtskopf (optional)
*/ */
export function annexIVSkeleton(system: AISystem, pack: ContentPack, today: string): string { export function annexIVSkeleton(
system: AISystem,
pack: ContentPack,
today: string,
profile?: CompanyProfile,
): string {
const todo = '_[AUSFÜLLEN]_'; const todo = '_[AUSFÜLLEN]_';
const lines = [header('Technische Dokumentation (Annex IV) — Grundgerüst', system, pack, today)]; const lines = [
header('Technische Dokumentation (Annex IV) — Grundgerüst', system, pack, today, profile),
];
lines.push( lines.push(
'## 1. Allgemeine Beschreibung des KI-Systems', '## 1. Allgemeine Beschreibung des KI-Systems',
@ -188,16 +224,19 @@ export function annexIVSkeleton(system: AISystem, pack: ContentPack, today: stri
* @param systems - Alle registrierten KI-Systeme * @param systems - Alle registrierten KI-Systeme
* @param pack - Regulatorischer Content-Pack * @param pack - Regulatorischer Content-Pack
* @param today - Stichtag im ISO-Format * @param today - Stichtag im ISO-Format
* @param profile - Firmenprofil für den Berichtskopf (optional)
*/ */
export function inventoryReport( export function inventoryReport(
systems: readonly AISystem[], systems: readonly AISystem[],
pack: ContentPack, pack: ContentPack,
today: string, today: string,
profile?: CompanyProfile,
): string { ): string {
const company = profile && profile.name.trim() !== '' ? `${profile.name} · ` : '';
const lines = [ const lines = [
'# KI-Inventar (AI Asset Register)', '# KI-Inventar (AI Asset Register)',
'', '',
`Stand: ${formatDate(today)} · Content-Pack v${pack.version}`, `${company}Stand: ${formatDate(today)} · Content-Pack v${pack.version}`,
'', '',
DISCLAIMER, DISCLAIMER,
'', '',

View File

@ -81,6 +81,19 @@ export interface AISystem {
readonly obligationStatus: Readonly<Record<string, ObligationStatus>>; readonly obligationStatus: Readonly<Record<string, ObligationStatus>>;
} }
/** Firmenprofil für personalisierte Berichte (Briefkopf, PDF-Druck). */
export interface CompanyProfile {
readonly name: string;
readonly street: string;
readonly zipCity: string;
readonly contactPerson: string;
readonly email: string;
readonly phone: string;
readonly website: string;
/** Logo als Data-URL (bleibt lokal im Browser), leer = kein Logo. */
readonly logoDataUrl: string;
}
/** Eintrag im regulatorischen Content-Pack (Checklisten-Item mit Rechtsgrundlage). */ /** Eintrag im regulatorischen Content-Pack (Checklisten-Item mit Rechtsgrundlage). */
export interface ContentItem { export interface ContentItem {
readonly id: string; readonly id: string;

View File

@ -0,0 +1,112 @@
import type { CompanyProfile } from '../engine/types';
import { renderMarkdown } from './render';
/** Maskiert HTML-Sonderzeichen für den Briefkopf. */
function esc(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** Baut den Briefkopf (Logo, Firmenname, Kontaktdaten) als HTML. */
function letterhead(profile: CompanyProfile): string {
const addressLines = [profile.street, profile.zipCity].filter((l) => l.trim() !== '');
const contactLines = [
profile.contactPerson,
profile.email,
profile.phone,
profile.website,
].filter((l) => l.trim() !== '');
if (profile.name.trim() === '' && profile.logoDataUrl === '' && contactLines.length === 0) {
return '';
}
const logo = profile.logoDataUrl
? `<img class="lh-logo" src="${profile.logoDataUrl}" alt="Logo">`
: '';
return `
<header class="letterhead">
<div class="lh-company">
<div class="lh-name">${esc(profile.name)}</div>
<div class="lh-lines">${addressLines.map(esc).join('<br>')}</div>
<div class="lh-lines">${contactLines.map(esc).join(' · ')}</div>
</div>
${logo}
</header>`;
}
/** Fußzeile mit Kontaktdaten für jede gedruckte Seite. */
function footer(profile: CompanyProfile): string {
const parts = [profile.name, profile.street, profile.zipCity, profile.email, profile.phone]
.filter((p) => p.trim() !== '')
.map(esc);
return parts.length > 0 ? `<footer class="pagefoot">${parts.join(' · ')}</footer>` : '';
}
const PRINT_CSS = `
@page { size: A4; margin: 22mm 20mm; }
* { box-sizing: border-box; }
body {
font-family: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
color: #14120e; margin: 0; font-size: 11.5pt; line-height: 1.55; background: #fff;
}
.letterhead { display: flex; justify-content: space-between; align-items: flex-start;
border-bottom: 2.5pt solid #14120e; padding-bottom: 10pt; margin-bottom: 18pt; }
.lh-name { font-size: 15pt; font-weight: 700; letter-spacing: 0.02em; }
.lh-lines { font-size: 9pt; color: #444; margin-top: 3pt;
font-family: ui-monospace, 'Courier New', monospace; }
.lh-logo { max-height: 52pt; max-width: 160pt; object-fit: contain; }
h1 { font-size: 17pt; margin: 0 0 12pt; line-height: 1.25;
border-bottom: 1pt solid #14120e; padding-bottom: 6pt; }
h2 { font-size: 13pt; margin: 16pt 0 6pt; }
h3 { font-size: 11.5pt; margin: 12pt 0 4pt; }
p { margin: 0 0 8pt; }
ul { margin: 0 0 8pt; padding-left: 16pt; }
li { margin-bottom: 3pt; }
blockquote { margin: 10pt 0; padding: 6pt 10pt; border-left: 2.5pt solid #a3271e;
font-size: 9.5pt; color: #444; }
table { width: 100%; border-collapse: collapse; margin: 6pt 0 12pt; font-size: 9.5pt; }
th { text-align: left; border-bottom: 1.5pt solid #14120e; padding: 4pt 6pt;
font-family: ui-monospace, 'Courier New', monospace; font-size: 8pt;
text-transform: uppercase; letter-spacing: 0.06em; }
td { border-bottom: 0.5pt solid #999; padding: 4pt 6pt; vertical-align: top; }
em { color: #a3271e; font-style: italic; }
.pagefoot { margin-top: 24pt; padding-top: 6pt; border-top: 0.75pt solid #14120e;
font-size: 7.5pt; color: #555; font-family: ui-monospace, 'Courier New', monospace; }
`;
/**
* Öffnet ein Dokument als druckfertige Ansicht mit Firmen-Briefkopf und
* startet den Druckdialog (PDF via "Als PDF speichern" des Browsers).
* Alles läuft lokal keine Server-Roundtrips.
*
* @param title - Fenstertitel (wird auch Standard-PDF-Dateiname)
* @param markdown - Vom Dokumenten-Generator erzeugtes Markdown
* @param profile - Firmenprofil für Briefkopf und Fußzeile
*/
export function printDocument(title: string, markdown: string, profile: CompanyProfile): void {
const win = window.open('', '_blank', 'width=900,height=1000');
if (!win) {
window.alert('Popup blockiert — bitte Popups für diese Seite erlauben, um drucken zu können.');
return;
}
win.document.write(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>${esc(title)}</title>
<style>${PRINT_CSS}</style>
</head>
<body>
${letterhead(profile)}
${renderMarkdown(markdown)}
${footer(profile)}
<script>window.addEventListener('load', function () { window.print(); });</script>
</body>
</html>`);
win.document.close();
}

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { renderMarkdown } from './render';
describe('renderMarkdown', () => {
it('rendert Überschriften aller Ebenen', () => {
const html = renderMarkdown('# H1\n\n## H2\n\n### H3');
expect(html).toContain('<h1>H1</h1>');
expect(html).toContain('<h2>H2</h2>');
expect(html).toContain('<h3>H3</h3>');
});
it('rendert Tabellen mit Kopfzeile', () => {
const html = renderMarkdown('| Pflicht | Frist |\n|---|---|\n| QMS | 2027 |');
expect(html).toContain('<th>Pflicht</th>');
expect(html).toContain('<td>QMS</td>');
expect(html).not.toContain('---');
});
it('rendert Meta-Tabellen mit leerer Kopfzeile', () => {
const html = renderMarkdown('| | |\n|---|---|\n| **KI-System** | Chatbot |');
expect(html).toContain('<strong>KI-System</strong>');
expect(html).toContain('<td>Chatbot</td>');
});
it('rendert Listen und Blockquotes', () => {
const html = renderMarkdown('- Punkt eins\n- Punkt zwei\n\n> Hinweis');
expect(html).toContain('<li>Punkt eins</li>');
expect(html).toContain('<blockquote>Hinweis</blockquote>');
});
it('rendert Inline-Formatierung', () => {
const html = renderMarkdown('Das ist **wichtig** und _offen_.');
expect(html).toContain('<strong>wichtig</strong>');
expect(html).toContain('<em>offen</em>');
});
it('maskiert HTML in Eingaben (kein Injection über Systemnamen)', () => {
const html = renderMarkdown('# <script>alert(1)</script>');
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
});

View File

@ -0,0 +1,125 @@
/**
* Minimaler Markdown-Renderer für die intern erzeugten Dokumente.
*
* Unterstützt genau die Konstrukte, die der Dokumenten-Generator ausgibt:
* Überschriften (#/##/###), Tabellen, Listen, Blockquotes, Absätze sowie
* **fett** und _kursiv_. Kein externer Parser bewusst klein und auditierbar.
*/
/** Maskiert HTML-Sonderzeichen. */
function escapeHtml(text: string): string {
return text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
/** Wandelt Inline-Markdown (**fett**, _kursiv_) in HTML um. */
function inline(text: string): string {
return escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/_(.+?)_/g, '<em>$1</em>');
}
function isTableRow(line: string): boolean {
return line.trimStart().startsWith('|');
}
function isSeparatorRow(line: string): boolean {
return /^\s*\|[\s\-|:]+\|\s*$/.test(line);
}
function splitCells(line: string): readonly string[] {
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '');
return trimmed.split('|').map((c) => c.trim());
}
function renderTable(rows: readonly string[]): string {
const [first, second] = rows;
const hasHeader = first !== undefined && second !== undefined && isSeparatorRow(second);
const bodyRows = hasHeader ? rows.slice(2) : rows.filter((r) => !isSeparatorRow(r));
const parts: string[] = ['<table>'];
if (hasHeader) {
const headerCells = splitCells(first)
.map((c) => `<th>${inline(c)}</th>`)
.join('');
parts.push(`<thead><tr>${headerCells}</tr></thead>`);
}
parts.push('<tbody>');
for (const row of bodyRows) {
const cells = splitCells(row)
.map((c) => `<td>${inline(c)}</td>`)
.join('');
parts.push(`<tr>${cells}</tr>`);
}
parts.push('</tbody></table>');
return parts.join('');
}
/**
* Rendert die intern erzeugten Markdown-Dokumente als HTML.
*
* @param markdown - Vom Dokumenten-Generator erzeugtes Markdown
* @returns HTML-Fragment (ohne umgebendes Dokument)
*/
export function renderMarkdown(markdown: string): string {
const lines = markdown.split('\n');
const out: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i] ?? '';
if (line.trim() === '') {
i += 1;
continue;
}
if (line.startsWith('### ')) {
out.push(`<h3>${inline(line.slice(4))}</h3>`);
i += 1;
continue;
}
if (line.startsWith('## ')) {
out.push(`<h2>${inline(line.slice(3))}</h2>`);
i += 1;
continue;
}
if (line.startsWith('# ')) {
out.push(`<h1>${inline(line.slice(2))}</h1>`);
i += 1;
continue;
}
if (line.startsWith('> ')) {
const quote: string[] = [];
while (i < lines.length && (lines[i] ?? '').startsWith('> ')) {
quote.push(inline((lines[i] ?? '').slice(2)));
i += 1;
}
out.push(`<blockquote>${quote.join('<br>')}</blockquote>`);
continue;
}
if (line.startsWith('- ')) {
const items: string[] = [];
while (i < lines.length && (lines[i] ?? '').startsWith('- ')) {
items.push(`<li>${inline((lines[i] ?? '').slice(2))}</li>`);
i += 1;
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
if (isTableRow(line)) {
const rows: string[] = [];
while (i < lines.length && isTableRow(lines[i] ?? '')) {
rows.push(lines[i] ?? '');
i += 1;
}
out.push(renderTable(rows));
continue;
}
out.push(`<p>${inline(line)}</p>`);
i += 1;
}
return out.join('\n');
}

View File

@ -1,13 +1,36 @@
import type { AISystem } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
const STORAGE_KEY = 'ai-act-kompass/systems/v1'; const STORAGE_KEY = 'ai-act-kompass/systems/v1';
const PROFILE_KEY = 'ai-act-kompass/profile/v1';
/** Format der Export-/Import-Datei. */ /** Format der Export-/Import-Datei. */
export interface ExportFile { export interface ExportFile {
readonly app: 'ai-act-kompass'; readonly app: 'ai-act-kompass';
readonly schemaVersion: 1; readonly schemaVersion: 1 | 2;
readonly exportedAt: string; readonly exportedAt: string;
readonly systems: readonly AISystem[]; readonly systems: readonly AISystem[];
/** Ab schemaVersion 2 enthalten. */
readonly profile?: CompanyProfile;
}
/** Ergebnis eines Imports (Profil optional, ältere Backups haben keins). */
export interface ImportResult {
readonly systems: readonly AISystem[];
readonly profile: CompanyProfile | null;
}
/** Leeres Firmenprofil (Default). */
export function emptyProfile(): CompanyProfile {
return {
name: '',
street: '',
zipCity: '',
contactPerson: '',
email: '',
phone: '',
website: '',
logoDataUrl: '',
};
} }
/** /**
@ -33,30 +56,56 @@ export function saveSystems(systems: readonly AISystem[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(systems)); localStorage.setItem(STORAGE_KEY, JSON.stringify(systems));
} }
/** Lädt das Firmenprofil aus dem lokalen Speicher (fail-safe). */
export function loadProfile(): CompanyProfile {
try {
const raw = localStorage.getItem(PROFILE_KEY);
if (!raw) return emptyProfile();
const parsed: unknown = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null
? { ...emptyProfile(), ...(parsed as Partial<CompanyProfile>) }
: emptyProfile();
} catch {
return emptyProfile();
}
}
/** Persistiert das Firmenprofil im lokalen Speicher des Browsers. */
export function saveProfile(profile: CompanyProfile): void {
localStorage.setItem(PROFILE_KEY, JSON.stringify(profile));
}
/** /**
* Serialisiert alle Daten als Export-Datei (JSON) für Backup/Umzug. * Serialisiert alle Daten als Export-Datei (JSON) für Backup/Umzug.
* *
* @param systems - Zu exportierende Systeme * @param systems - Zu exportierende Systeme
* @param profile - Firmenprofil
* @param exportedAt - Zeitstempel im ISO-Format * @param exportedAt - Zeitstempel im ISO-Format
*/ */
export function exportData(systems: readonly AISystem[], exportedAt: string): string { export function exportData(
systems: readonly AISystem[],
profile: CompanyProfile,
exportedAt: string,
): string {
const file: ExportFile = { const file: ExportFile = {
app: 'ai-act-kompass', app: 'ai-act-kompass',
schemaVersion: 1, schemaVersion: 2,
exportedAt, exportedAt,
systems, systems,
profile,
}; };
return JSON.stringify(file, null, 2); return JSON.stringify(file, null, 2);
} }
/** /**
* Liest eine Export-Datei ein und validiert das Grundformat. * Liest eine Export-Datei ein und validiert das Grundformat.
* Versteht schemaVersion 1 (ohne Profil) und 2 (mit Profil).
* *
* @param json - Inhalt der Export-Datei * @param json - Inhalt der Export-Datei
* @returns Systeme aus der Datei * @returns Systeme und ggf. Firmenprofil aus der Datei
* @throws Error bei unbekanntem oder defektem Format * @throws Error bei unbekanntem oder defektem Format
*/ */
export function importData(json: string): readonly AISystem[] { export function importData(json: string): ImportResult {
const parsed: unknown = JSON.parse(json); const parsed: unknown = JSON.parse(json);
if ( if (
typeof parsed !== 'object' || typeof parsed !== 'object' ||
@ -66,10 +115,15 @@ export function importData(json: string): readonly AISystem[] {
) { ) {
throw new Error('Unbekanntes Dateiformat — keine gültige AI-Act-Kompass-Exportdatei.'); throw new Error('Unbekanntes Dateiformat — keine gültige AI-Act-Kompass-Exportdatei.');
} }
return (parsed as ExportFile).systems; const file = parsed as ExportFile;
return {
systems: file.systems,
profile: file.profile ? { ...emptyProfile(), ...file.profile } : null,
};
} }
/** Löscht alle lokal gespeicherten Daten unwiderruflich. */ /** Löscht alle lokal gespeicherten Daten unwiderruflich. */
export function clearAll(): void { export function clearAll(): void {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PROFILE_KEY);
} }

View File

@ -1,185 +1,286 @@
/*
* AI-Act-Kompass Anti-AI-Design.
* Gestaltungsprinzip: technisches Druckwerk statt SaaS-Dashboard.
* Papierton, Serifenschrift, Hairlines, rechteckige "Stempel"
* keine Verläufe, keine Schatten, keine Rundungen, keine Emojis.
*/
:root { :root {
--bg: #f5f6f8; --paper: #f4f1ea;
--panel: #ffffff; --panel: #fdfcf8;
--ink: #1a2233; --ink: #14120e;
--muted: #5b6577; --muted: #6b675e;
--line: #e3e6ec; --line: #14120e;
--accent: #1f4fd8; --hairline: #c9c4b8;
--accent-ink: #ffffff; --accent: #a3271e;
--danger: #b3261e; --ok: #2d5d3a;
--warn: #9a6700; --warn: #8a6d1a;
--ok: #1a7f37; --serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
--radius: 10px; --mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace;
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink); } body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: var(--serif);
}
.layout { display: flex; min-height: 100vh; } .layout { display: flex; min-height: 100vh; }
/* ── Seitenleiste: Kolumnentitel eines Fachblatts ─────────────────── */
.sidebar { .sidebar {
width: 240px; width: 250px;
flex-shrink: 0; flex-shrink: 0;
background: #101a33; background: var(--paper);
color: #d7deee; border-right: 2px solid var(--ink);
padding: 20px 14px; padding: 26px 18px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
} }
.sidebar .brand { .sidebar .brand {
font-size: 18px; font-size: 21px;
font-weight: 700; font-weight: 700;
color: #fff; letter-spacing: 0.01em;
margin-bottom: 2px; border-bottom: 3px double var(--ink);
padding-bottom: 12px;
} }
.sidebar .brand small { display: block; font-size: 11px; font-weight: 400; color: #8fa0c4; margin-top: 4px; } .sidebar .brand small {
.sidebar nav { display: flex; flex-direction: column; gap: 2px; margin-top: 18px; } display: block;
font-family: var(--mono);
font-size: 10px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-top: 8px;
}
.sidebar nav { display: flex; flex-direction: column; margin-top: 22px; }
.sidebar button { .sidebar button {
text-align: left; text-align: left;
background: none; background: none;
border: none; border: none;
color: #c3cde3; border-bottom: 1px solid var(--hairline);
padding: 9px 12px; color: var(--ink);
border-radius: 8px; padding: 12px 4px;
font-size: 14px; font-family: var(--serif);
font-size: 15px;
cursor: pointer; cursor: pointer;
} }
.sidebar button:hover { background: rgba(255, 255, 255, 0.08); } .sidebar button:hover { color: var(--accent); }
.sidebar button.active { background: var(--accent); color: #fff; } .sidebar button.active {
.sidebar .foot { margin-top: auto; font-size: 11px; color: #7787ab; line-height: 1.5; } font-weight: 700;
border-left: 4px solid var(--accent);
padding-left: 10px;
}
.sidebar .foot {
margin-top: auto;
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
line-height: 1.8;
border-top: 1px solid var(--ink);
padding-top: 12px;
}
.main { flex: 1; padding: 28px 36px; max-width: 1040px; } .main { flex: 1; padding: 32px 40px; max-width: 1060px; }
h1 { font-size: 22px; margin: 0 0 4px; } h1 { font-size: 26px; margin: 0 0 6px; font-weight: 700; }
h2 { font-size: 16px; margin: 22px 0 10px; } h2 { font-size: 16px; margin: 22px 0 10px; }
.sub { color: var(--muted); font-size: 14px; margin: 0 0 20px; } .sub { color: var(--muted); font-size: 15px; margin: 0 0 22px; font-style: italic; }
.panel { .panel {
background: var(--panel); background: var(--panel);
border: 1px solid var(--line); border: 1px solid var(--ink);
border-radius: var(--radius); padding: 20px 22px;
padding: 18px 20px; margin-bottom: 18px;
margin-bottom: 16px;
} }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; } /* ── Kennzahlen ───────────────────────────────────────────────────── */
.stat { text-align: left; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0; }
.stat .num { font-size: 28px; font-weight: 700; } .grid .stat { padding: 0 18px; border-left: 1px solid var(--hairline); }
.stat .lbl { font-size: 13px; color: var(--muted); } .grid .stat:first-child { border-left: none; padding-left: 0; }
.stat .num { font-size: 34px; font-weight: 700; font-variant-numeric: tabular-nums; }
.stat .lbl {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin-top: 2px;
}
/* ── Tabellen ─────────────────────────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: 14px; } table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; } th {
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); } text-align: left;
font-family: var(--mono);
color: var(--ink);
font-weight: 600;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
border-bottom: 2px solid var(--ink);
}
th, td { padding: 10px; }
td { border-bottom: 1px solid var(--hairline); vertical-align: top; }
tr.clickable { cursor: pointer; } tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: #f0f3fa; } tr.clickable:hover td { background: #efe9dc; }
/* ── Risiko-Stempel ───────────────────────────────────────────────── */
.badge { .badge {
display: inline-block; display: inline-block;
padding: 3px 10px; padding: 2px 9px;
border-radius: 99px; border: 1.5px solid currentColor;
font-size: 12px; font-family: var(--mono);
font-weight: 600; font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
white-space: nowrap; white-space: nowrap;
background: transparent;
} }
.badge.high { background: #fde8e7; color: var(--danger); } .badge.high { color: var(--accent); }
.badge.prohibited { background: var(--danger); color: #fff; } .badge.prohibited { color: var(--panel); background: var(--accent); border-color: var(--accent); }
.badge.limited { background: #fff3d6; color: var(--warn); } .badge.limited { color: var(--warn); }
.badge.minimal { background: #e2f5e8; color: var(--ok); } .badge.minimal { color: var(--ok); }
.badge.out-of-scope { background: #e8eaf0; color: var(--muted); } .badge.out-of-scope { color: var(--muted); }
.badge.status-open { background: #fde8e7; color: var(--danger); }
.badge.status-in-progress { background: #fff3d6; color: var(--warn); }
.badge.status-done { background: #e2f5e8; color: var(--ok); }
.badge.status-not-applicable { background: #e8eaf0; color: var(--muted); }
/* ── Buttons: Stempelkissen-Optik ─────────────────────────────────── */
button.primary, button.ghost, button.danger { button.primary, button.ghost, button.danger {
border-radius: 8px;
padding: 9px 16px; padding: 9px 16px;
font-size: 14px; font-family: var(--mono);
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
cursor: pointer; cursor: pointer;
border: 1px solid transparent; border: 1.5px solid var(--ink);
background: var(--panel);
color: var(--ink);
} }
button.primary { background: var(--accent); color: var(--accent-ink); } button.primary { background: var(--ink); color: var(--paper); }
button.primary:hover { filter: brightness(1.1); } button.primary:hover { background: var(--accent); border-color: var(--accent); }
button.primary:disabled { opacity: 0.5; cursor: not-allowed; } button.primary:disabled { opacity: 0.4; cursor: not-allowed; }
button.ghost { background: #fff; border-color: var(--line); color: var(--ink); }
button.ghost:hover { border-color: var(--accent); color: var(--accent); } button.ghost:hover { border-color: var(--accent); color: var(--accent); }
button.danger { background: #fff; border-color: var(--danger); color: var(--danger); } button.danger { border-color: var(--accent); color: var(--accent); }
button.danger:hover { background: var(--accent); color: var(--paper); }
/* ── Formulare ────────────────────────────────────────────────────── */
label.field { display: block; margin-bottom: 14px; font-size: 14px; } label.field { display: block; margin-bottom: 14px; font-size: 14px; }
label.field span { display: block; font-weight: 600; margin-bottom: 5px; } label.field span {
display: block;
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 5px;
}
input[type='text'], select, textarea { input[type='text'], select, textarea {
width: 100%; width: 100%;
padding: 9px 11px; padding: 9px 11px;
border: 1px solid var(--line); border: 1px solid var(--ink);
border-radius: 8px;
font-size: 14px; font-size: 14px;
font-family: inherit; font-family: var(--serif);
background: #fff; background: #fff;
color: var(--ink); color: var(--ink);
border-radius: 0;
} }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; } input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 22px; }
.logo-row { display: flex; align-items: center; gap: 16px; margin-top: 6px; flex-wrap: wrap; }
.logo-preview {
max-height: 56px;
max-width: 180px;
object-fit: contain;
border: 1px solid var(--hairline);
padding: 6px;
background: #fff;
}
.check { .check {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: flex-start; align-items: flex-start;
padding: 10px 12px; padding: 11px 13px;
border: 1px solid var(--line); border: 1px solid var(--hairline);
border-radius: 8px; border-left: 3px solid var(--ink);
margin-bottom: 8px; margin-bottom: 8px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14.5px;
background: var(--panel);
} }
.check:hover { border-color: var(--accent); } .check:hover { border-left-color: var(--accent); }
.check input { margin-top: 3px; } .check input { margin-top: 4px; accent-color: var(--accent); }
.check .ref { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; } .check .ref {
display: block;
.wizard-steps { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; } font-family: var(--mono);
.wizard-steps .step { font-size: 11px;
font-size: 12px;
padding: 5px 11px;
border-radius: 99px;
background: #e8eaf0;
color: var(--muted); color: var(--muted);
margin-top: 3px;
} }
.wizard-steps .step.current { background: var(--accent); color: #fff; }
.wizard-steps .step.done { background: #cdd8f6; color: var(--accent); } /* ── Wizard ───────────────────────────────────────────────────────── */
.wizard-steps { display: flex; gap: 0; margin-bottom: 20px; flex-wrap: wrap; border: 1px solid var(--ink); }
.wizard-steps .step {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 7px 12px;
border-right: 1px solid var(--hairline);
color: var(--muted);
background: var(--panel);
}
.wizard-steps .step:last-child { border-right: none; }
.wizard-steps .step.current { background: var(--ink); color: var(--paper); }
.wizard-steps .step.done { color: var(--ink); font-weight: 700; }
.wizard-nav { display: flex; justify-content: space-between; margin-top: 20px; } .wizard-nav { display: flex; justify-content: space-between; margin-top: 20px; }
.reasons li { margin-bottom: 6px; font-size: 14px; } .reasons li { margin-bottom: 7px; font-size: 14.5px; }
.hint { font-size: 13px; color: var(--muted); } .hint { font-size: 13px; color: var(--muted); }
.disclaimer { .disclaimer {
font-size: 12px; font-family: var(--mono);
font-size: 11px;
color: var(--muted); color: var(--muted);
border-left: 3px solid var(--warn); border-left: 3px solid var(--accent);
padding: 8px 12px; padding: 9px 13px;
background: #fffaf0; background: var(--panel);
border-radius: 0 8px 8px 0; margin-top: 22px;
margin-top: 20px;
} }
/* ── Fristen ──────────────────────────────────────────────────────── */
.deadline-list { list-style: none; padding: 0; margin: 0; } .deadline-list { list-style: none; padding: 0; margin: 0; }
.deadline-list li { .deadline-list li {
display: flex; display: flex;
gap: 12px; gap: 14px;
padding: 9px 0; padding: 10px 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--hairline);
font-size: 14px; font-size: 14.5px;
align-items: baseline; align-items: baseline;
} }
.deadline-list li:last-child { border-bottom: none; } .deadline-list li:last-child { border-bottom: none; }
.deadline-list .date { font-weight: 700; white-space: nowrap; min-width: 92px; } .deadline-list .date {
.deadline-list .past { color: var(--muted); } font-family: var(--mono);
font-weight: 700;
font-size: 13px;
white-space: nowrap;
min-width: 96px;
}
.deadline-list .past { color: var(--muted); text-decoration: line-through; }
/* ── Dokumentvorschau ─────────────────────────────────────────────── */
.doc-preview { .doc-preview {
background: #0f172a; background: #fff;
color: #dbe4f5; color: var(--ink);
border-radius: 8px; border: 1px solid var(--ink);
padding: 16px; padding: 18px;
font-size: 12.5px; font-family: var(--mono);
font-size: 12px;
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap; white-space: pre-wrap;
max-height: 420px; max-height: 420px;
@ -187,4 +288,4 @@ input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); ou
} }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 16px; } .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 16px; }
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 14px; } .empty { text-align: center; color: var(--muted); padding: 44px 0; font-size: 14.5px; font-style: italic; }

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { AISystem } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
import { loadSystems, saveSystems } from '../store/storage'; import { loadProfile, loadSystems, saveProfile, saveSystems } from '../store/storage';
import { Dashboard } from './Dashboard'; import { Dashboard } from './Dashboard';
import { SettingsView } from './SettingsView'; import { SettingsView } from './SettingsView';
import { SystemDetail } from './SystemDetail'; import { SystemDetail } from './SystemDetail';
@ -15,12 +15,17 @@ type View =
/** App-Shell: Navigation, State-Verwaltung und Persistenz. */ /** App-Shell: Navigation, State-Verwaltung und Persistenz. */
export function App(): JSX.Element { export function App(): JSX.Element {
const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems()); const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems());
const [profile, setProfile] = useState<CompanyProfile>(() => loadProfile());
const [view, setView] = useState<View>({ kind: 'dashboard' }); const [view, setView] = useState<View>({ kind: 'dashboard' });
useEffect(() => { useEffect(() => {
saveSystems(systems); saveSystems(systems);
}, [systems]); }, [systems]);
useEffect(() => {
saveProfile(profile);
}, [profile]);
const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id); const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id);
const upsert = (system: AISystem): void => { const upsert = (system: AISystem): void => {
@ -39,7 +44,7 @@ export function App(): JSX.Element {
<div className="layout"> <div className="layout">
<aside className="sidebar"> <aside className="sidebar">
<div className="brand"> <div className="brand">
🧭 AI-Act-Kompass AI-Act-Kompass
<small>EU AI Act Compliance für KMU · local-first</small> <small>EU AI Act Compliance für KMU · local-first</small>
</div> </div>
<nav> <nav>
@ -76,6 +81,7 @@ export function App(): JSX.Element {
{view.kind === 'dashboard' && ( {view.kind === 'dashboard' && (
<Dashboard <Dashboard
systems={systems} systems={systems}
profile={profile}
onOpen={(id) => setView({ kind: 'detail', systemId: id })} onOpen={(id) => setView({ kind: 'detail', systemId: id })}
onNew={() => setView({ kind: 'wizard', systemId: null })} onNew={() => setView({ kind: 'wizard', systemId: null })}
/> />
@ -101,6 +107,7 @@ export function App(): JSX.Element {
return ( return (
<SystemDetail <SystemDetail
system={system} system={system}
profile={profile}
onUpdate={upsert} onUpdate={upsert}
onReassess={() => setView({ kind: 'wizard', systemId: system.id })} onReassess={() => setView({ kind: 'wizard', systemId: system.id })}
onDelete={() => { onDelete={() => {
@ -116,7 +123,12 @@ export function App(): JSX.Element {
{view.kind === 'settings' && ( {view.kind === 'settings' && (
<SettingsView <SettingsView
systems={systems} systems={systems}
onImport={(imported) => setSystems(imported)} profile={profile}
onProfileChange={setProfile}
onImport={(imported, importedProfile) => {
setSystems(imported);
if (importedProfile) setProfile(importedProfile);
}}
onClear={() => setSystems([])} onClear={() => setSystems([])}
/> />
)} )}

View File

@ -1,16 +1,18 @@
import { CONTENT_PACK } from '../content/pack'; import { CONTENT_PACK } from '../content/pack';
import { inventoryReport } from '../engine/documents'; import { inventoryReport } from '../engine/documents';
import type { AISystem } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
import { printDocument } from '../print/print';
import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common'; import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common';
interface DashboardProps { interface DashboardProps {
readonly systems: readonly AISystem[]; readonly systems: readonly AISystem[];
readonly profile: CompanyProfile;
readonly onOpen: (id: string) => void; readonly onOpen: (id: string) => void;
readonly onNew: () => void; readonly onNew: () => void;
} }
/** Übersichtsseite: Kennzahlen, Fristen und das KI-Inventar. */ /** Übersichtsseite: Kennzahlen, Fristen und das KI-Inventar. */
export function Dashboard({ systems, onOpen, onNew }: DashboardProps): JSX.Element { export function Dashboard({ systems, profile, onOpen, onNew }: DashboardProps): JSX.Element {
const today = todayISO(); const today = todayISO();
const count = (risk: string): number => const count = (risk: string): number =>
systems.filter((s) => s.classification.riskClass === risk).length; systems.filter((s) => s.classification.riskClass === risk).length;
@ -69,14 +71,31 @@ export function Dashboard({ systems, onOpen, onNew }: DashboardProps): JSX.Eleme
<h2 style={{ margin: 0 }}>KI-Inventar</h2> <h2 style={{ margin: 0 }}>KI-Inventar</h2>
<div className="toolbar" style={{ margin: 0 }}> <div className="toolbar" style={{ margin: 0 }}>
{systems.length > 0 && ( {systems.length > 0 && (
<button <>
className="ghost" <button
onClick={() => className="ghost"
downloadText(`ki-inventar-${today}.md`, inventoryReport(systems, CONTENT_PACK, today)) onClick={() =>
} printDocument(
> `ki-inventar-${today}`,
Inventar exportieren inventoryReport(systems, CONTENT_PACK, today, profile),
</button> profile,
)
}
>
Inventar drucken / PDF
</button>
<button
className="ghost"
onClick={() =>
downloadText(
`ki-inventar-${today}.md`,
inventoryReport(systems, CONTENT_PACK, today, profile),
)
}
>
.md
</button>
</>
)} )}
<button className="primary" onClick={onNew}> <button className="primary" onClick={onNew}>
+ System bewerten + System bewerten

View File

@ -1,35 +1,128 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { CONTENT_PACK } from '../content/pack'; import { CONTENT_PACK } from '../content/pack';
import type { AISystem } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
import { clearAll, exportData, importData } from '../store/storage'; import { clearAll, exportData, importData } from '../store/storage';
import { downloadText, todayISO } from './common'; import { downloadText, todayISO } from './common';
interface SettingsViewProps { interface SettingsViewProps {
readonly systems: readonly AISystem[]; readonly systems: readonly AISystem[];
readonly onImport: (systems: readonly AISystem[]) => void; readonly profile: CompanyProfile;
readonly onProfileChange: (profile: CompanyProfile) => void;
readonly onImport: (systems: readonly AISystem[], profile: CompanyProfile | null) => void;
readonly onClear: () => void; readonly onClear: () => void;
} }
/** Einstellungen: Datensicherung, Import, Löschen, Content-Pack-Info. */ const MAX_LOGO_BYTES = 300_000;
export function SettingsView({ systems, onImport, onClear }: SettingsViewProps): JSX.Element {
const PROFILE_FIELDS: readonly { key: keyof CompanyProfile; label: string; placeholder: string }[] = [
{ key: 'name', label: 'Firmenname', placeholder: 'Muster GmbH' },
{ key: 'street', label: 'Straße & Hausnummer', placeholder: 'Musterstraße 1' },
{ key: 'zipCity', label: 'PLZ & Ort', placeholder: '60311 Frankfurt am Main' },
{ key: 'contactPerson', label: 'Ansprechpartner', placeholder: 'Max Mustermann' },
{ key: 'email', label: 'E-Mail', placeholder: 'compliance@muster.de' },
{ key: 'phone', label: 'Telefon', placeholder: '+49 69 000000' },
{ key: 'website', label: 'Website', placeholder: 'www.muster.de' },
];
/** Einstellungen: Firmenprofil (Briefkopf), Datensicherung, Löschen, Content-Pack-Info. */
export function SettingsView({
systems,
profile,
onProfileChange,
onImport,
onClear,
}: SettingsViewProps): JSX.Element {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const logoInput = useRef<HTMLInputElement>(null);
const [message, setMessage] = useState<string>(''); const [message, setMessage] = useState<string>('');
const [logoMessage, setLogoMessage] = useState<string>('');
const handleImport = async (file: File): Promise<void> => { const handleImport = async (file: File): Promise<void> => {
try { try {
const imported = importData(await file.text()); const result = importData(await file.text());
onImport(imported); onImport(result.systems, result.profile);
setMessage(`${imported.length} System(e) importiert.`); setMessage(`Import abgeschlossen: ${result.systems.length} System(e)${result.profile ? ' inkl. Firmenprofil' : ''}.`);
} catch (err) { } catch (err) {
setMessage(`⚠️ Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`); setMessage(`Import fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`);
} }
}; };
const handleLogo = (file: File): void => {
if (!file.type.startsWith('image/')) {
setLogoMessage('Bitte eine Bilddatei wählen (PNG, JPG oder SVG).');
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
if (dataUrl.length > MAX_LOGO_BYTES * 1.4) {
setLogoMessage('Logo zu groß — bitte eine Datei unter 300 KB verwenden.');
return;
}
onProfileChange({ ...profile, logoDataUrl: dataUrl });
setLogoMessage('Logo übernommen.');
};
reader.readAsDataURL(file);
};
return ( return (
<div> <div>
<h1>Einstellungen</h1> <h1>Einstellungen</h1>
<p className="sub">Datenhoheit: Alles liegt lokal in diesem Browser kein Cloud-Sync, keine Telemetrie.</p> <p className="sub">Datenhoheit: Alles liegt lokal in diesem Browser kein Cloud-Sync, keine Telemetrie.</p>
<div className="panel">
<h2 style={{ marginTop: 0 }}>Firmenprofil (Briefkopf für Berichte & PDF-Druck)</h2>
<p className="hint">
Diese Angaben erscheinen im Kopf und in der Fußzeile aller generierten und gedruckten
Dokumente. Das Logo bleibt lokal gespeichert und verlässt das Gerät nicht.
</p>
<div className="profile-grid">
{PROFILE_FIELDS.map((f) => (
<label key={f.key} className="field">
<span>{f.label}</span>
<input
type="text"
value={profile[f.key]}
placeholder={f.placeholder}
onChange={(e) => onProfileChange({ ...profile, [f.key]: e.target.value })}
/>
</label>
))}
</div>
<div className="logo-row">
{profile.logoDataUrl ? (
<img className="logo-preview" src={profile.logoDataUrl} alt="Firmenlogo" />
) : (
<span className="hint">Kein Logo hinterlegt.</span>
)}
<div className="toolbar" style={{ margin: 0 }}>
<button className="ghost" onClick={() => logoInput.current?.click()}>
Logo hochladen
</button>
{profile.logoDataUrl && (
<button
className="danger"
onClick={() => onProfileChange({ ...profile, logoDataUrl: '' })}
>
Logo entfernen
</button>
)}
</div>
<input
ref={logoInput}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleLogo(file);
e.target.value = '';
}}
/>
</div>
{logoMessage && <p className="hint">{logoMessage}</p>}
</div>
<div className="panel"> <div className="panel">
<h2 style={{ marginTop: 0 }}>Regulatorischer Content-Pack</h2> <h2 style={{ marginTop: 0 }}>Regulatorischer Content-Pack</h2>
<p style={{ fontSize: 14 }}> <p style={{ fontSize: 14 }}>
@ -52,7 +145,7 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
onClick={() => onClick={() =>
downloadText( downloadText(
`ai-act-kompass-backup-${todayISO()}.json`, `ai-act-kompass-backup-${todayISO()}.json`,
exportData(systems, new Date().toISOString()), exportData(systems, profile, new Date().toISOString()),
'application/json', 'application/json',
) )
} }
@ -79,14 +172,14 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
<div className="panel"> <div className="panel">
<h2 style={{ marginTop: 0 }}>Alle Daten löschen</h2> <h2 style={{ marginTop: 0 }}>Alle Daten löschen</h2>
<p className="hint">Entfernt alle erfassten KI-Systeme unwiderruflich aus diesem Browser.</p> <p className="hint">Entfernt alle erfassten KI-Systeme und das Firmenprofil unwiderruflich aus diesem Browser.</p>
<button <button
className="danger" className="danger"
onClick={() => { onClick={() => {
if (window.confirm('Wirklich alle lokalen Daten unwiderruflich löschen?')) { if (window.confirm('Wirklich alle lokalen Daten unwiderruflich löschen?')) {
clearAll(); clearAll();
onClear(); onClear();
setMessage('Alle Daten gelöscht.'); setMessage('Alle Daten gelöscht.');
} }
}} }}
> >

View File

@ -2,11 +2,13 @@ import { useMemo, useState } from 'react';
import { CONTENT_PACK } from '../content/pack'; import { CONTENT_PACK } from '../content/pack';
import { actionPlan, annexIVSkeleton, riskReport } from '../engine/documents'; import { actionPlan, annexIVSkeleton, riskReport } from '../engine/documents';
import { deriveObligations } from '../engine/obligations'; import { deriveObligations } from '../engine/obligations';
import type { AISystem, ObligationStatus } from '../engine/types'; import type { AISystem, CompanyProfile, ObligationStatus } from '../engine/types';
import { printDocument } from '../print/print';
import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common'; import { downloadText, RiskBadge, ROLE_LABELS, todayISO } from './common';
interface SystemDetailProps { interface SystemDetailProps {
readonly system: AISystem; readonly system: AISystem;
readonly profile: CompanyProfile;
readonly onUpdate: (system: AISystem) => void; readonly onUpdate: (system: AISystem) => void;
readonly onReassess: () => void; readonly onReassess: () => void;
readonly onDelete: () => void; readonly onDelete: () => void;
@ -25,6 +27,7 @@ type DocKind = 'report' | 'plan' | 'annexIV';
/** Detailansicht eines Systems: Klassifizierung, Pflichten-Checkliste, Dokumente. */ /** Detailansicht eines Systems: Klassifizierung, Pflichten-Checkliste, Dokumente. */
export function SystemDetail({ export function SystemDetail({
system, system,
profile,
onUpdate, onUpdate,
onReassess, onReassess,
onDelete, onDelete,
@ -50,17 +53,17 @@ export function SystemDetail({
report: { report: {
label: 'Risikobericht', label: 'Risikobericht',
file: `risikobericht-${system.name}-${today}.md`, file: `risikobericht-${system.name}-${today}.md`,
render: () => riskReport(system, CONTENT_PACK, today), render: () => riskReport(system, CONTENT_PACK, today, profile),
}, },
plan: { plan: {
label: 'Maßnahmenplan', label: 'Maßnahmenplan',
file: `massnahmenplan-${system.name}-${today}.md`, file: `massnahmenplan-${system.name}-${today}.md`,
render: () => actionPlan(system, obligations, CONTENT_PACK, today), render: () => actionPlan(system, obligations, CONTENT_PACK, today, profile),
}, },
annexIV: { annexIV: {
label: 'Annex-IV-Dokumentation', label: 'Annex-IV-Dokumentation',
file: `annex-iv-${system.name}-${today}.md`, file: `annex-iv-${system.name}-${today}.md`,
render: () => annexIVSkeleton(system, CONTENT_PACK, today), render: () => annexIVSkeleton(system, CONTENT_PACK, today, profile),
}, },
}; };
@ -150,9 +153,15 @@ export function SystemDetail({
</button> </button>
<button <button
className="primary" className="primary"
onClick={() => printDocument(docs[kind].file.replace(/\.md$/, ''), docs[kind].render(), profile)}
>
Drucken / PDF
</button>
<button
className="ghost"
onClick={() => downloadText(docs[kind].file, docs[kind].render())} onClick={() => downloadText(docs[kind].file, docs[kind].render())}
> >
.md .md
</button> </button>
</span> </span>
))} ))}