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 { 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**');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
'',
|
'',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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([])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user