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:
parent
fb2da0db00
commit
06574c5cfd
@ -3,7 +3,7 @@ import { CONTENT_PACK } from '../content/pack';
|
||||
import { classify } from './classify';
|
||||
import { actionPlan, annexIVSkeleton, inventoryReport, riskReport } from './documents';
|
||||
import { deriveObligations } from './obligations';
|
||||
import type { AISystem, AssessmentAnswers } from './types';
|
||||
import type { AISystem, AssessmentAnswers, CompanyProfile } from './types';
|
||||
|
||||
const answers: AssessmentAnswers = {
|
||||
isAISystem: true,
|
||||
@ -63,4 +63,27 @@ describe('documents', () => {
|
||||
expect(md).toContain('| System |');
|
||||
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**');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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> = {
|
||||
'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;
|
||||
}
|
||||
|
||||
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 [
|
||||
`# ${title}`,
|
||||
'',
|
||||
`| | |`,
|
||||
`|---|---|`,
|
||||
...companyRows(profile),
|
||||
`| **KI-System** | ${system.name} |`,
|
||||
`| **Zweckbestimmung** | ${system.purpose || '—'} |`,
|
||||
`| **Hersteller/Anbieter** | ${system.vendor || '—'} |`,
|
||||
@ -57,9 +77,15 @@ function header(title: string, system: AISystem, pack: ContentPack, today: strin
|
||||
* @param system - Bewertetes KI-System
|
||||
* @param pack - Regulatorischer Content-Pack
|
||||
* @param today - Stichtag im ISO-Format
|
||||
* @param profile - Firmenprofil für den Berichtskopf (optional)
|
||||
*/
|
||||
export function riskReport(system: AISystem, pack: ContentPack, today: string): string {
|
||||
const lines = [header('Risikoklassifizierung nach EU AI Act', system, pack, today)];
|
||||
export function riskReport(
|
||||
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(`**${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 pack - Regulatorischer Content-Pack
|
||||
* @param today - Stichtag im ISO-Format
|
||||
* @param profile - Firmenprofil für den Berichtskopf (optional)
|
||||
*/
|
||||
export function actionPlan(
|
||||
system: AISystem,
|
||||
obligations: readonly Obligation[],
|
||||
pack: ContentPack,
|
||||
today: string,
|
||||
profile?: CompanyProfile,
|
||||
): 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) {
|
||||
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 pack - Regulatorischer Content-Pack
|
||||
* @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 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(
|
||||
'## 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 pack - Regulatorischer Content-Pack
|
||||
* @param today - Stichtag im ISO-Format
|
||||
* @param profile - Firmenprofil für den Berichtskopf (optional)
|
||||
*/
|
||||
export function inventoryReport(
|
||||
systems: readonly AISystem[],
|
||||
pack: ContentPack,
|
||||
today: string,
|
||||
profile?: CompanyProfile,
|
||||
): string {
|
||||
const company = profile && profile.name.trim() !== '' ? `${profile.name} · ` : '';
|
||||
const lines = [
|
||||
'# KI-Inventar (AI Asset Register)',
|
||||
'',
|
||||
`Stand: ${formatDate(today)} · Content-Pack v${pack.version}`,
|
||||
`${company}Stand: ${formatDate(today)} · Content-Pack v${pack.version}`,
|
||||
'',
|
||||
DISCLAIMER,
|
||||
'',
|
||||
|
||||
@ -81,6 +81,19 @@ export interface AISystem {
|
||||
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). */
|
||||
export interface ContentItem {
|
||||
readonly id: string;
|
||||
|
||||
112
ai-act-kompass/src/print/print.ts
Normal file
112
ai-act-kompass/src/print/print.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
42
ai-act-kompass/src/print/render.test.ts
Normal file
42
ai-act-kompass/src/print/render.test.ts
Normal 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('<script>');
|
||||
});
|
||||
});
|
||||
125
ai-act-kompass/src/print/render.ts
Normal file
125
ai-act-kompass/src/print/render.ts
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
/** 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');
|
||||
}
|
||||
@ -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 PROFILE_KEY = 'ai-act-kompass/profile/v1';
|
||||
|
||||
/** Format der Export-/Import-Datei. */
|
||||
export interface ExportFile {
|
||||
readonly app: 'ai-act-kompass';
|
||||
readonly schemaVersion: 1;
|
||||
readonly schemaVersion: 1 | 2;
|
||||
readonly exportedAt: string;
|
||||
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));
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*
|
||||
* @param systems - Zu exportierende Systeme
|
||||
* @param profile - Firmenprofil
|
||||
* @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 = {
|
||||
app: 'ai-act-kompass',
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
exportedAt,
|
||||
systems,
|
||||
profile,
|
||||
};
|
||||
return JSON.stringify(file, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Export-Datei ein und validiert das Grundformat.
|
||||
* Versteht schemaVersion 1 (ohne Profil) und 2 (mit Profil).
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export function importData(json: string): readonly AISystem[] {
|
||||
export function importData(json: string): ImportResult {
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (
|
||||
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.');
|
||||
}
|
||||
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. */
|
||||
export function clearAll(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(PROFILE_KEY);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
--bg: #f5f6f8;
|
||||
--panel: #ffffff;
|
||||
--ink: #1a2233;
|
||||
--muted: #5b6577;
|
||||
--line: #e3e6ec;
|
||||
--accent: #1f4fd8;
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #b3261e;
|
||||
--warn: #9a6700;
|
||||
--ok: #1a7f37;
|
||||
--radius: 10px;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--paper: #f4f1ea;
|
||||
--panel: #fdfcf8;
|
||||
--ink: #14120e;
|
||||
--muted: #6b675e;
|
||||
--line: #14120e;
|
||||
--hairline: #c9c4b8;
|
||||
--accent: #a3271e;
|
||||
--ok: #2d5d3a;
|
||||
--warn: #8a6d1a;
|
||||
--serif: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
* { 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; }
|
||||
|
||||
/* ── Seitenleiste: Kolumnentitel eines Fachblatts ─────────────────── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
width: 250px;
|
||||
flex-shrink: 0;
|
||||
background: #101a33;
|
||||
color: #d7deee;
|
||||
padding: 20px 14px;
|
||||
background: var(--paper);
|
||||
border-right: 2px solid var(--ink);
|
||||
padding: 26px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.sidebar .brand {
|
||||
font-size: 18px;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 2px;
|
||||
letter-spacing: 0.01em;
|
||||
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 nav { display: flex; flex-direction: column; gap: 2px; margin-top: 18px; }
|
||||
.sidebar .brand small {
|
||||
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 {
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c3cde3;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
color: var(--ink);
|
||||
padding: 12px 4px;
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar button:hover { background: rgba(255, 255, 255, 0.08); }
|
||||
.sidebar button.active { background: var(--accent); color: #fff; }
|
||||
.sidebar .foot { margin-top: auto; font-size: 11px; color: #7787ab; line-height: 1.5; }
|
||||
.sidebar button:hover { color: var(--accent); }
|
||||
.sidebar button.active {
|
||||
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; }
|
||||
.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 {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--ink);
|
||||
padding: 20px 22px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
|
||||
.stat { text-align: left; }
|
||||
.stat .num { font-size: 28px; font-weight: 700; }
|
||||
.stat .lbl { font-size: 13px; color: var(--muted); }
|
||||
/* ── Kennzahlen ───────────────────────────────────────────────────── */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0; }
|
||||
.grid .stat { padding: 0 18px; border-left: 1px solid var(--hairline); }
|
||||
.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; }
|
||||
th { text-align: left; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
th, td { padding: 9px 10px; border-bottom: 1px solid var(--line); }
|
||||
th {
|
||||
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:hover td { background: #f0f3fa; }
|
||||
tr.clickable:hover td { background: #efe9dc; }
|
||||
|
||||
/* ── Risiko-Stempel ───────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border: 1.5px solid currentColor;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
}
|
||||
.badge.high { background: #fde8e7; color: var(--danger); }
|
||||
.badge.prohibited { background: var(--danger); color: #fff; }
|
||||
.badge.limited { background: #fff3d6; color: var(--warn); }
|
||||
.badge.minimal { background: #e2f5e8; color: var(--ok); }
|
||||
.badge.out-of-scope { background: #e8eaf0; 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); }
|
||||
.badge.high { color: var(--accent); }
|
||||
.badge.prohibited { color: var(--panel); background: var(--accent); border-color: var(--accent); }
|
||||
.badge.limited { color: var(--warn); }
|
||||
.badge.minimal { color: var(--ok); }
|
||||
.badge.out-of-scope { color: var(--muted); }
|
||||
|
||||
/* ── Buttons: Stempelkissen-Optik ─────────────────────────────────── */
|
||||
button.primary, button.ghost, button.danger {
|
||||
border-radius: 8px;
|
||||
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;
|
||||
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:hover { filter: brightness(1.1); }
|
||||
button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.ghost { background: #fff; border-color: var(--line); color: var(--ink); }
|
||||
button.primary { background: var(--ink); color: var(--paper); }
|
||||
button.primary:hover { background: var(--accent); border-color: var(--accent); }
|
||||
button.primary:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
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 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 {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ink);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
font-family: var(--serif);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
border-radius: 0;
|
||||
}
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 11px 13px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-left: 3px solid var(--ink);
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 14.5px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.check:hover { border-color: var(--accent); }
|
||||
.check input { margin-top: 3px; }
|
||||
.check .ref { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.wizard-steps { display: flex; gap: 6px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
.wizard-steps .step {
|
||||
font-size: 12px;
|
||||
padding: 5px 11px;
|
||||
border-radius: 99px;
|
||||
background: #e8eaf0;
|
||||
.check:hover { border-left-color: var(--accent); }
|
||||
.check input { margin-top: 4px; accent-color: var(--accent); }
|
||||
.check .ref {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
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; }
|
||||
|
||||
.reasons li { margin-bottom: 6px; font-size: 14px; }
|
||||
.reasons li { margin-bottom: 7px; font-size: 14.5px; }
|
||||
.hint { font-size: 13px; color: var(--muted); }
|
||||
.disclaimer {
|
||||
font-size: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
border-left: 3px solid var(--warn);
|
||||
padding: 8px 12px;
|
||||
background: #fffaf0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin-top: 20px;
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 9px 13px;
|
||||
background: var(--panel);
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
/* ── Fristen ──────────────────────────────────────────────────────── */
|
||||
.deadline-list { list-style: none; padding: 0; margin: 0; }
|
||||
.deadline-list li {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 14px;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
font-size: 14.5px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.deadline-list li:last-child { border-bottom: none; }
|
||||
.deadline-list .date { font-weight: 700; white-space: nowrap; min-width: 92px; }
|
||||
.deadline-list .past { color: var(--muted); }
|
||||
.deadline-list .date {
|
||||
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 {
|
||||
background: #0f172a;
|
||||
color: #dbe4f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
font-size: 12.5px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--ink);
|
||||
padding: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
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; }
|
||||
.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; }
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AISystem } from '../engine/types';
|
||||
import { loadSystems, saveSystems } from '../store/storage';
|
||||
import type { AISystem, CompanyProfile } from '../engine/types';
|
||||
import { loadProfile, loadSystems, saveProfile, saveSystems } from '../store/storage';
|
||||
import { Dashboard } from './Dashboard';
|
||||
import { SettingsView } from './SettingsView';
|
||||
import { SystemDetail } from './SystemDetail';
|
||||
@ -15,12 +15,17 @@ type View =
|
||||
/** App-Shell: Navigation, State-Verwaltung und Persistenz. */
|
||||
export function App(): JSX.Element {
|
||||
const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems());
|
||||
const [profile, setProfile] = useState<CompanyProfile>(() => loadProfile());
|
||||
const [view, setView] = useState<View>({ kind: 'dashboard' });
|
||||
|
||||
useEffect(() => {
|
||||
saveSystems(systems);
|
||||
}, [systems]);
|
||||
|
||||
useEffect(() => {
|
||||
saveProfile(profile);
|
||||
}, [profile]);
|
||||
|
||||
const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id);
|
||||
|
||||
const upsert = (system: AISystem): void => {
|
||||
@ -39,7 +44,7 @@ export function App(): JSX.Element {
|
||||
<div className="layout">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
🧭 AI-Act-Kompass
|
||||
AI-Act-Kompass
|
||||
<small>EU AI Act Compliance für KMU · local-first</small>
|
||||
</div>
|
||||
<nav>
|
||||
@ -76,6 +81,7 @@ export function App(): JSX.Element {
|
||||
{view.kind === 'dashboard' && (
|
||||
<Dashboard
|
||||
systems={systems}
|
||||
profile={profile}
|
||||
onOpen={(id) => setView({ kind: 'detail', systemId: id })}
|
||||
onNew={() => setView({ kind: 'wizard', systemId: null })}
|
||||
/>
|
||||
@ -101,6 +107,7 @@ export function App(): JSX.Element {
|
||||
return (
|
||||
<SystemDetail
|
||||
system={system}
|
||||
profile={profile}
|
||||
onUpdate={upsert}
|
||||
onReassess={() => setView({ kind: 'wizard', systemId: system.id })}
|
||||
onDelete={() => {
|
||||
@ -116,7 +123,12 @@ export function App(): JSX.Element {
|
||||
{view.kind === 'settings' && (
|
||||
<SettingsView
|
||||
systems={systems}
|
||||
onImport={(imported) => setSystems(imported)}
|
||||
profile={profile}
|
||||
onProfileChange={setProfile}
|
||||
onImport={(imported, importedProfile) => {
|
||||
setSystems(imported);
|
||||
if (importedProfile) setProfile(importedProfile);
|
||||
}}
|
||||
onClear={() => setSystems([])}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import { CONTENT_PACK } from '../content/pack';
|
||||
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';
|
||||
|
||||
interface DashboardProps {
|
||||
readonly systems: readonly AISystem[];
|
||||
readonly profile: CompanyProfile;
|
||||
readonly onOpen: (id: string) => void;
|
||||
readonly onNew: () => void;
|
||||
}
|
||||
|
||||
/** Ü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 count = (risk: string): number =>
|
||||
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>
|
||||
<div className="toolbar" style={{ margin: 0 }}>
|
||||
{systems.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() =>
|
||||
downloadText(`ki-inventar-${today}.md`, inventoryReport(systems, CONTENT_PACK, today))
|
||||
printDocument(
|
||||
`ki-inventar-${today}`,
|
||||
inventoryReport(systems, CONTENT_PACK, today, profile),
|
||||
profile,
|
||||
)
|
||||
}
|
||||
>
|
||||
Inventar exportieren
|
||||
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}>
|
||||
+ System bewerten
|
||||
|
||||
@ -1,35 +1,128 @@
|
||||
import { useRef, useState } from 'react';
|
||||
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 { downloadText, todayISO } from './common';
|
||||
|
||||
interface SettingsViewProps {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Einstellungen: Datensicherung, Import, Löschen, Content-Pack-Info. */
|
||||
export function SettingsView({ systems, onImport, onClear }: SettingsViewProps): JSX.Element {
|
||||
const MAX_LOGO_BYTES = 300_000;
|
||||
|
||||
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 logoInput = useRef<HTMLInputElement>(null);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const [logoMessage, setLogoMessage] = useState<string>('');
|
||||
|
||||
const handleImport = async (file: File): Promise<void> => {
|
||||
try {
|
||||
const imported = importData(await file.text());
|
||||
onImport(imported);
|
||||
setMessage(`✓ ${imported.length} System(e) importiert.`);
|
||||
const result = importData(await file.text());
|
||||
onImport(result.systems, result.profile);
|
||||
setMessage(`Import abgeschlossen: ${result.systems.length} System(e)${result.profile ? ' inkl. Firmenprofil' : ''}.`);
|
||||
} 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 (
|
||||
<div>
|
||||
<h1>Einstellungen</h1>
|
||||
<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">
|
||||
<h2 style={{ marginTop: 0 }}>Regulatorischer Content-Pack</h2>
|
||||
<p style={{ fontSize: 14 }}>
|
||||
@ -52,7 +145,7 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
|
||||
onClick={() =>
|
||||
downloadText(
|
||||
`ai-act-kompass-backup-${todayISO()}.json`,
|
||||
exportData(systems, new Date().toISOString()),
|
||||
exportData(systems, profile, new Date().toISOString()),
|
||||
'application/json',
|
||||
)
|
||||
}
|
||||
@ -79,14 +172,14 @@ export function SettingsView({ systems, onImport, onClear }: SettingsViewProps):
|
||||
|
||||
<div className="panel">
|
||||
<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
|
||||
className="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('Wirklich alle lokalen Daten unwiderruflich löschen?')) {
|
||||
clearAll();
|
||||
onClear();
|
||||
setMessage('✓ Alle Daten gelöscht.');
|
||||
setMessage('Alle Daten gelöscht.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@ -2,11 +2,13 @@ import { useMemo, useState } from 'react';
|
||||
import { CONTENT_PACK } from '../content/pack';
|
||||
import { actionPlan, annexIVSkeleton, riskReport } from '../engine/documents';
|
||||
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';
|
||||
|
||||
interface SystemDetailProps {
|
||||
readonly system: AISystem;
|
||||
readonly profile: CompanyProfile;
|
||||
readonly onUpdate: (system: AISystem) => void;
|
||||
readonly onReassess: () => void;
|
||||
readonly onDelete: () => void;
|
||||
@ -25,6 +27,7 @@ type DocKind = 'report' | 'plan' | 'annexIV';
|
||||
/** Detailansicht eines Systems: Klassifizierung, Pflichten-Checkliste, Dokumente. */
|
||||
export function SystemDetail({
|
||||
system,
|
||||
profile,
|
||||
onUpdate,
|
||||
onReassess,
|
||||
onDelete,
|
||||
@ -50,17 +53,17 @@ export function SystemDetail({
|
||||
report: {
|
||||
label: 'Risikobericht',
|
||||
file: `risikobericht-${system.name}-${today}.md`,
|
||||
render: () => riskReport(system, CONTENT_PACK, today),
|
||||
render: () => riskReport(system, CONTENT_PACK, today, profile),
|
||||
},
|
||||
plan: {
|
||||
label: 'Maßnahmenplan',
|
||||
file: `massnahmenplan-${system.name}-${today}.md`,
|
||||
render: () => actionPlan(system, obligations, CONTENT_PACK, today),
|
||||
render: () => actionPlan(system, obligations, CONTENT_PACK, today, profile),
|
||||
},
|
||||
annexIV: {
|
||||
label: 'Annex-IV-Dokumentation',
|
||||
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
|
||||
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())}
|
||||
>
|
||||
⬇ .md
|
||||
.md
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user