4 Commits

Author SHA1 Message Date
Benjamin Admin
51a208a2e1 feat(company-profile): KI-Systeme als eigener Wizard-Schritt mit Vorlagen
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 34s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 25s
CI / test-python-dsms-gateway (push) Successful in 20s
Schritt 6 (Verarbeitung & KI) aufgeteilt: Step 6 zeigt nur noch
Verarbeitungstaetigkeiten, Step 7 ist ein neuer KI-Systeme-Schritt mit
18 vorgefertigten Vorlagen in 7 Kategorien (Text-KI, Office, Code, Bild,
Uebersetzung, CRM, Intern). Jede Vorlage hat anklickbare Einsatzzweck-Chips,
Datenschutz-Warnhinweise und vorausgefuellte Felder. Link zum AI-Act-Modul
am Ende des Schritts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 23:06:23 +01:00
Benjamin Admin
1c59996f32 feat(wiki): Enrich wiki with DACH court decisions and 18 new articles
- Update all 10 existing articles with real source URLs (EuGH, BAG, DSK, BfDI)
- Add 18 new articles covering:
  - EuGH C-184/20 (wide interpretation Art. 9)
  - EuGH C-667/21 (cumulative legal basis)
  - EuGH C-34/21 (§26 BDSG unconstitutional)
  - EuGH C-634/21 (SCHUFA scoring)
  - EuGH C-582/14 (IP addresses as personal data)
  - Biometric data, indirect Art. 9 data in daily practice
  - Retention periods overview
  - Video surveillance and GPS tracking at workplace
  - Communication data (email/chat, Fernmeldegeheimnis)
  - Financial data, PCI DSS, SEPA
  - Minors (Art. 8 DSGVO)
  - Austria DSG specifics, Switzerland revDSG
  - AI training data and GDPR/AI Act
  - "Forced" special categories
- Add 3 new categories (EuGH-Leiturteile, Aufbewahrungsfristen, DACH-Besonderheiten)
- Add code block rendering to markdown renderer
- Add Clock, Globe, Gavel icons to icon map
- Total: 11 categories, 28 articles, all with verified source URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:43:23 +01:00
Benjamin Admin
61064fdcba fix: Cast empty ARRAY[] to text[] in wiki migration
PostgreSQL requires explicit type cast for empty array literals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:12:54 +01:00
Benjamin Admin
11d4c2fd36 feat: Add Compliance Wiki as internal admin knowledge base
Migration 040 with wiki_categories + wiki_articles tables, 10 seed
articles across 8 categories (DSGVO, Art. 9, AVV, HinSchG etc.).
Read-only FastAPI API, Next.js proxy, and two-column frontend with
full-text search.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 20:01:27 +01:00
10 changed files with 2821 additions and 82 deletions

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
/**
* Proxy: GET /api/sdk/v1/wiki?endpoint=...
*
* Routes to backend wiki endpoints:
* endpoint=categories → GET /api/compliance/v1/wiki/categories
* endpoint=articles → GET /api/compliance/v1/wiki/articles(?category_id=...)
* endpoint=search → GET /api/compliance/v1/wiki/search?q=...
* endpoint=article&id= → GET /api/compliance/v1/wiki/articles/{id}
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const endpoint = searchParams.get('endpoint') || 'categories'
let backendPath: string
switch (endpoint) {
case 'categories':
backendPath = '/api/compliance/v1/wiki/categories'
break
case 'articles': {
const categoryId = searchParams.get('category_id')
backendPath = '/api/compliance/v1/wiki/articles'
if (categoryId) {
backendPath += `?category_id=${encodeURIComponent(categoryId)}`
}
break
}
case 'article': {
const articleId = searchParams.get('id')
if (!articleId) {
return NextResponse.json(
{ error: 'Missing article id' },
{ status: 400 }
)
}
backendPath = `/api/compliance/v1/wiki/articles/${encodeURIComponent(articleId)}`
break
}
case 'search': {
const query = searchParams.get('q')
if (!query) {
return NextResponse.json(
{ error: 'Missing search query' },
{ status: 400 }
)
}
backendPath = `/api/compliance/v1/wiki/search?q=${encodeURIComponent(query)}`
break
}
default:
return NextResponse.json(
{ error: `Unknown endpoint: ${endpoint}` },
{ status: 400 }
)
}
const response = await fetch(`${BACKEND_URL}${backendPath}`)
if (!response.ok) {
if (response.status === 404) {
return NextResponse.json(null, { status: 404 })
}
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Wiki proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -34,11 +34,12 @@ const BASE_WIZARD_STEPS = [
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
{ id: 5, name: 'Datenschutz', description: 'Rollen und KI-Nutzung' },
{ id: 6, name: 'Verarbeitung & KI', description: 'Verarbeitungstätigkeiten und KI-Systeme' },
{ id: 7, name: 'Rechtlicher Rahmen', description: 'Regulierungen und Prüfzyklen' },
{ id: 6, name: 'Verarbeitungstaetigkeiten', description: 'Datenverarbeitung nach Art. 30 DSGVO' },
{ id: 7, name: 'KI-Systeme', description: 'Eingesetzte KI-Systeme erfassen' },
{ id: 8, name: 'Rechtlicher Rahmen', description: 'Regulierungen und Prüfzyklen' },
]
const MACHINE_BUILDER_STEP = { id: 8, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
const MACHINE_BUILDER_STEP = { id: 9, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
function getWizardSteps(industry: string) {
if (isMachineBuilderIndustry(industry)) {
@@ -200,9 +201,10 @@ const STEP_EXPLANATIONS: Record<number, string> = {
3: 'Die Unternehmensgröße bestimmt, ob Sie einen DSB benennen müssen (ab 20 MA), ob NIS2-Pflichten greifen und welche Audit-Anforderungen gelten.',
4: 'Standorte und Zielmärkte bestimmen, welche nationalen Datenschutzgesetze zusätzlich zur DSGVO greifen (z.B. BDSG, DSG-AT, UK GDPR, CCPA).',
5: 'Ob Sie Verantwortlicher oder Auftragsverarbeiter sind, bestimmt Ihre DSGVO-Pflichten grundlegend. KI-Nutzung löst zusätzliche AI-Act-Pflichten aus.',
6: 'Ihre Verarbeitungstätigkeiten bilden die Grundlage für das Verarbeitungsverzeichnis (Art. 30 DSGVO). KI-Systeme werden für die AI-Act-Analyse erfasst — die Risikoeinstufung übernimmt unser Tool.',
7: 'Regulierungsrahmen und Prüfzyklen definieren, welche Compliance-Module für Sie aktiviert werden und in welchem Rhythmus Audits stattfinden.',
8: 'Als Maschinenbauer gelten zusätzliche Anforderungen: CE-Kennzeichnung, Maschinenverordnung, Produktsicherheit und ggf. Hochrisiko-KI im Sinne des AI Act.',
6: 'Ihre Verarbeitungstätigkeiten bilden die Grundlage für das Verarbeitungsverzeichnis (Art. 30 DSGVO).',
7: 'Erfassen Sie hier die KI-Systeme, die in Ihrem Unternehmen eingesetzt werden. Die Risikoeinstufung nach EU AI Act erfolgt automatisch im AI-Act-Modul.',
8: 'Regulierungsrahmen und Prüfzyklen definieren, welche Compliance-Module für Sie aktiviert werden und in welchem Rhythmus Audits stattfinden.',
9: 'Als Maschinenbauer gelten zusätzliche Anforderungen: CE-Kennzeichnung, Maschinenverordnung, Produktsicherheit und ggf. Hochrisiko-KI im Sinne des AI Act.',
}
function StepBusinessModel({
@@ -651,22 +653,22 @@ function StepDataProtection({
// DSGVO-Standard Datenkategorien
const ALL_DATA_CATEGORIES = [
{ id: 'stammdaten', label: 'Stammdaten', desc: 'Name, Geburtsdatum, Geschlecht', info: 'Vor- und Nachname, Geburtsdatum, Geschlecht, Anrede, Titel, Familienstand, Staatsangehörigkeit, Personalnummer, Kundennummer' },
{ id: 'kontaktdaten', label: 'Kontaktdaten', desc: 'E-Mail, Telefon, Adresse', info: 'E-Mail-Adresse, Telefonnummer, Mobilnummer, Postanschrift, Faxnummer, Messenger-IDs, Ansprechpartner-Namen bei Kunden/Lieferanten (z.B. SAP-Kontaktpersonen)' },
{ id: 'kontaktdaten', label: 'Kontaktdaten', desc: 'E-Mail, Telefon, Adresse', info: 'E-Mail-Adresse, Telefonnummer, Mobilnummer, Postanschrift, Faxnummer, Messenger-IDs der betroffenen Personen' },
{ id: 'vertragsdaten', label: 'Vertragsdaten', desc: 'Vertragsnummer, Laufzeit, Konditionen', info: 'Vertragsnummer, Vertragsbeginn/-ende, Laufzeit, Konditionen, Kündigungsfristen, Vertragsgegenstand, Bestellhistorie' },
{ id: 'zahlungsdaten', label: 'Zahlungs-/Bankdaten', desc: 'IBAN, Kreditkarte, Rechnungen', info: 'IBAN, BIC, Kontoinhaber, Kreditkartennummer, Rechnungsbeträge, Zahlungshistorie, Steuer-ID, USt-IdNr.' },
{ id: 'beschaeftigtendaten', label: 'Beschäftigtendaten', desc: 'Gehalt, Arbeitszeiten, Urlaub', info: 'Gehalt/Lohn, Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK), Arbeitszeiten, Urlaubstage, Abwesenheiten, Beurteilungen, Eintrittsdatum' },
{ id: 'beschaeftigtendaten', label: 'Beschäftigtendaten', desc: 'Gehalt, Arbeitszeiten, Urlaub', info: 'Gehalt/Lohn, Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK), Arbeitszeiten, Urlaubstage, Abwesenheiten, Beurteilungen, Eintrittsdatum. Aufbewahrung: i.d.R. 3 Jahre nach Austritt (§ 195 BGB), Lohndaten 8 Jahre (§ 147 AO)' },
{ id: 'kommunikation', label: 'Kommunikationsdaten', desc: 'E-Mail-Inhalte, Chat-Verläufe', info: 'E-Mail-Inhalte und -Metadaten, Chat-Nachrichten, Gesprächsprotokolle, Support-Tickets, Briefkorrespondenz' },
{ id: 'nutzungsdaten', label: 'Nutzungs-/Logdaten', desc: 'IP-Adressen, Login-Zeiten, Klicks', info: 'IP-Adressen, Login-Zeitpunkte, Seitenaufrufe, Klickverhalten, Geräteinformationen, Browser-Typ, Session-Dauer' },
{ id: 'standortdaten', label: 'Standortdaten', desc: 'GPS, Check-in, Lieferadressen', info: 'GPS-Koordinaten, Check-in/Check-out-Zeiten, Lieferadressen, Reiserouten, WLAN-Standortbestimmung' },
{ id: 'bilddaten', label: 'Bild-/Videodaten', desc: 'Fotos, Videoaufnahmen, Profilbilder', info: 'Profilfotos, Ausweiskopien, Videoaufnahmen (Überwachung), Bewerbungsfotos, Schulungsvideos' },
{ id: 'bewerberdaten', label: 'Bewerberdaten', desc: 'Lebenslauf, Zeugnisse, Anschreiben', info: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen, Verfügbarkeit, Bewerbungsquelle' },
{ id: 'bewerberdaten', label: 'Bewerberdaten', desc: 'Lebenslauf, Zeugnisse, Anschreiben', info: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen, Verfügbarkeit, Bewerbungsquelle. Löschfrist bei Absage: max. 6 Monate (AGG §§ 15, 21)' },
{ id: 'qualifikationsdaten', label: 'Qualifikations-/Schulungsdaten', desc: 'Fortbildungen, Zertifikate, Abschlüsse', info: 'Besuchte Seminare und Schulungen, Zertifikate, Abschlüsse, Qualifikationsnachweise, Schulungsdaten und -ergebnisse, Weiterbildungshistorie' },
] as const
const ALL_SPECIAL_CATEGORIES = [
{ id: 'gesundheit', label: 'Gesundheitsdaten', desc: 'Krankheitstage, Atteste, Diagnosen', info: 'Krankheitstage, AU-Bescheinigungen, Diagnosen, Behinderungsgrad (GdB), BEM-Daten, arbeitsmedizinische Untersuchungen, Impfstatus, Allergien. NICHT: Krankenkassenname (z.B. AOK, TK) — das sind normale Beschäftigtendaten.' },
{ id: 'biometrie', label: 'Biometrische Daten', desc: 'Fingerabdruck, Gesichtserkennung', info: 'Fingerabdruck, Gesichtserkennung, Iris-Scan, Stimmerkennung, Handvenenscan (zur eindeutigen Identifizierung)' },
{ id: 'religion', label: 'Religiöse Überzeugungen', desc: 'Konfession, Feiertage', info: 'Konfession (relevant für Kirchensteuer), religiöse Feiertage, Ernährungsvorschriften' },
{ id: 'gesundheit', label: 'Gesundheitsdaten', desc: 'Krankheitstage, Atteste, Diagnosen', info: 'Krankheitstage, AU-Bescheinigungen, Diagnosen, Behinderungsgrad (GdB), BEM-Daten, arbeitsmedizinische Untersuchungen, Impfstatus, Allergien. Auch AU ohne Diagnose = Gesundheitsdatum (LDI NRW). Schwangerschaft, Allergien, Online-Arzneimittelbestellung (EuGH C-21/23). NICHT: Krankenkassenname (z.B. AOK, TK) — das sind normale Beschäftigtendaten.' },
{ id: 'biometrie', label: 'Biometrische Daten', desc: 'Fingerabdruck, Gesichtserkennung', info: 'Fingerabdruck, Gesichtserkennung, Iris-Scan, Stimmerkennung, Handvenenscan. Nur wenn zur eindeutigen Identifizierung verwendet (ErwGr. 51). Einfaches Passfoto = kein biometrisches Datum.' },
{ id: 'religion', label: 'Religiöse Überzeugungen', desc: 'Konfession, Feiertage', info: 'Konfession (relevant für Kirchensteuer auf Lohnabrechnung), religiöse Feiertage, Ernährungsvorschriften. Auch indirekt: Kantinenbestellung halal/koscher (EuGH C-184/20 weite Auslegung).' },
{ id: 'gewerkschaft', label: 'Gewerkschaftszugehörigkeit', desc: 'Mitgliedschaft', info: 'Gewerkschaftsmitgliedschaft, Betriebsratszugehörigkeit, Tarifzugehörigkeit' },
{ id: 'genetik', label: 'Genetische Daten', desc: 'DNA, Erbkrankheiten', info: 'DNA-Analysen, genetische Prädispositionen, Erbkrankheitsrisiken (nur in Spezialfällen relevant)' },
] as const
@@ -683,6 +685,7 @@ interface ActivityTemplate {
default_legal_basis: string
legalHint?: string // Gesetzlicher Hinweis (z.B. ArbZG-Pflicht)
hasServiceProvider?: boolean // Kann ein externer Dienstleister eingesetzt werden?
categoryInfo?: Record<string, string> // Override Info-Text pro Datenkategorie (kontextspezifisch)
}
interface ActivityDepartment {
@@ -698,17 +701,17 @@ const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
{
id: 'personal', name: 'Personal / HR', icon: '👥',
activities: [
{ id: 'personalverwaltung', name: 'Personalverwaltung', purpose: 'Verwaltung von Beschäftigtendaten für das Arbeitsverhältnis', primary_categories: ['stammdaten', 'kontaktdaten', 'beschaeftigtendaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'religion', 'gewerkschaft'], default_legal_basis: 'contract' },
{ id: 'lohnbuchhaltung', name: 'Lohn- und Gehaltsabrechnung', purpose: 'Berechnung und Auszahlung von Löhnen und Gehältern', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'stammdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'legal', hasServiceProvider: true },
{ id: 'bewerbermanagement', name: 'Bewerbermanagement', purpose: 'Entgegennahme, Prüfung und Bearbeitung von Bewerbungen', primary_categories: ['bewerberdaten', 'stammdaten', 'kontaktdaten', 'kommunikation', 'qualifikationsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'consent' },
{ id: 'arbeitszeiterfassung', name: 'Arbeitszeiterfassung', purpose: 'Erfassung und Dokumentation der Arbeitszeiten', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'legal', legalHint: 'Gesetzlich vorgeschrieben (§ 3 ArbZG). Fehlende Arbeitszeiterfassung ist ein Compliance-Risiko.' },
{ id: 'personalverwaltung', name: 'Personalverwaltung', purpose: 'Verwaltung von Beschäftigtendaten für das Arbeitsverhältnis', primary_categories: ['stammdaten', 'kontaktdaten', 'beschaeftigtendaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'religion', 'gewerkschaft'], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Vor-/Nachname, Geburtsdatum, Geschlecht, Familienstand, Staatsangehörigkeit, Personalnummer', kontaktdaten: 'Privat- und Dienstadresse, Telefonnummern, dienstliche E-Mail, Notfallkontakt', beschaeftigtendaten: 'Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK — kein Gesundheitsdatum!), Eintrittsdatum, Arbeitszeit, Urlaubstage. Aufbewahrung: 3 Jahre nach Austritt (§ 195 BGB)', zahlungsdaten: 'IBAN für Gehaltsauszahlung, Vermögenswirksame Leistungen, Pfändungsdaten' } },
{ id: 'lohnbuchhaltung', name: 'Lohn- und Gehaltsabrechnung', purpose: 'Berechnung und Auszahlung von Löhnen und Gehältern', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'stammdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'legal', hasServiceProvider: true, categoryInfo: { beschaeftigtendaten: 'Gehalt, Zulagen, Prämien, Steuerklasse, SV-Nummer, Krankenkasse, Kirchensteuermerkmal (Achtung: Art. 9!). Aufbewahrung: Lohnabrechnungen 8 Jahre (§ 147 AO), Lohnsteuer 6 Jahre (§ 41 EStG)', zahlungsdaten: 'IBAN, Bankverbindung, Gehaltsabrechnungen, Pfändungsbeträge. Aufbewahrung: 8 Jahre (§ 147 AO)' } },
{ id: 'bewerbermanagement', name: 'Bewerbermanagement', purpose: 'Entgegennahme, Prüfung und Bearbeitung von Bewerbungen', primary_categories: ['bewerberdaten', 'stammdaten', 'kontaktdaten', 'kommunikation', 'qualifikationsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'consent', categoryInfo: { bewerberdaten: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen. Löschfrist bei Absage: max. 6 Monate (AGG §§ 15, 21)', kontaktdaten: 'Privatadresse, E-Mail, Telefonnummer des Bewerbers', kommunikation: 'Bewerbungskorrespondenz, Einladungen, Absageschreiben' } },
{ id: 'arbeitszeiterfassung', name: 'Arbeitszeiterfassung', purpose: 'Erfassung und Dokumentation der Arbeitszeiten', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'legal', legalHint: 'Gesetzlich vorgeschrieben (§ 3 ArbZG). Fehlende Arbeitszeiterfassung ist ein Compliance-Risiko.', categoryInfo: { beschaeftigtendaten: 'Beginn/Ende der Arbeitszeit, Pausen, Überstunden, Ruhezeiten. Aufbewahrung: mind. 2 Jahre (§ 16 Abs. 2 ArbZG). Nicht für Leistungskontrolle verwenden!' } },
{ id: 'weiterbildung', name: 'Fort- und Weiterbildung', purpose: 'Verwaltung von Schulungen und Weiterbildungsmaßnahmen', primary_categories: ['qualifikationsdaten', 'beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
},
{
id: 'finanzen', name: 'Finanzen / Buchhaltung', icon: '💰',
activities: [
{ id: 'finanzbuchhaltung', name: 'Finanzbuchhaltung', purpose: 'Buchführung, Rechnungsstellung, steuerliche Dokumentation', primary_categories: ['stammdaten', 'zahlungsdaten', 'vertragsdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'finanzbuchhaltung', name: 'Finanzbuchhaltung', purpose: 'Buchführung, Rechnungsstellung, steuerliche Dokumentation', primary_categories: ['stammdaten', 'zahlungsdaten', 'vertragsdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { zahlungsdaten: 'Rechnungsbeträge, IBAN, Buchungsbelege, USt-IdNr. Aufbewahrung: 8 Jahre (§ 147 AO)', vertragsdaten: 'Vertragsnummer, Konditionen, Bestellhistorie. Aufbewahrung: Handelskorrespondenz 6 Jahre (§ 257 HGB)', kontaktdaten: 'Rechnungsadresse, Ansprechpartner in der Debitorenbuchhaltung' } },
{ id: 'zahlungsverkehr', name: 'Zahlungsverkehr', purpose: 'Abwicklung von ein- und ausgehenden Zahlungen', primary_categories: ['zahlungsdaten', 'stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'mahnwesen', name: 'Mahnwesen / Inkasso', purpose: 'Überwachung offener Forderungen und Mahnverfahren', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'interest' },
{ id: 'reisekostenabrechnung', name: 'Reisekostenabrechnung', purpose: 'Abrechnung und Erstattung von Dienstreisekosten', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
@@ -717,7 +720,7 @@ const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
{
id: 'vertrieb', name: 'Vertrieb / Sales', icon: '📈',
activities: [
{ id: 'crm', name: 'CRM / Kundenverwaltung', purpose: 'Verwaltung von Kundenbeziehungen, Kontakthistorie, Verkaufschancen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'crm', name: 'CRM / Kundenverwaltung', purpose: 'Verwaltung von Kundenbeziehungen, Kontakthistorie, Verkaufschancen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Firmenname, Ansprechpartner-Name, Titel, Position, Kundennummer', kontaktdaten: 'Geschäftliche E-Mail, Telefon, Büroadresse des Ansprechpartners. B2B-Kontaktdaten sind personenbezogene Daten — Art. 13 DSGVO Informationspflicht gilt!', kommunikation: 'E-Mail-Korrespondenz, Gesprächsnotizen, Support-Tickets, Meeting-Protokolle' } },
{ id: 'angebotserstellung', name: 'Angebotserstellung', purpose: 'Erstellung und Nachverfolgung von Angeboten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
{ id: 'vertragsmanagement', name: 'Vertragsmanagement', purpose: 'Verwaltung, Archivierung und Nachverfolgung von Verträgen', primary_categories: ['vertragsdaten', 'stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'contract' },
],
@@ -746,7 +749,7 @@ const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
activities: [
{ id: 'datenschutzanfragen', name: 'Betroffenenrechte (DSGVO)', purpose: 'Bearbeitung von Auskunfts-, Lösch- und Berichtigungsanfragen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'auftragsverarbeitung', name: 'Auftragsverarbeitung (AVV)', purpose: 'Dokumentation und Verwaltung von Auftragsverarbeitungsverhältnissen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'whistleblowing', name: 'Hinweisgebersystem', purpose: 'Entgegennahme und Bearbeitung von Meldungen nach HinSchG', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal' },
{ id: 'whistleblowing', name: 'Hinweisgebersystem', purpose: 'Entgegennahme und Bearbeitung von Meldungen nach HinSchG', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { stammdaten: 'Identität des Hinweisgebers (besonders schützenswert! § 8 HinSchG Vertraulichkeitsgebot)', kontaktdaten: 'Kontaktdaten nur für zuständige Meldestelle zugänglich', kommunikation: 'Meldungsinhalt, Kommunikationsverlauf, Zeugenaussagen. Löschfrist: 3 Jahre nach Abschluss (§ 11 Abs. 5 HinSchG)' } },
],
},
]
@@ -791,7 +794,7 @@ const OPTIONAL_DEPARTMENTS: ActivityDepartment[] = [
id: 'facility', name: 'Facility Management', icon: '🏢',
activities: [
{ id: 'zutrittskontrolle', name: 'Zutrittskontrolle', purpose: 'Kontrolle und Protokollierung des Zutritts zu Gebäuden und Räumen', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'bilddaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest' },
{ id: 'videoueberwachung', name: 'Videoüberwachung', purpose: 'Überwachung von Gebäuden und Geländen mittels Videokameras', primary_categories: ['bilddaten', 'beschaeftigtendaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest' },
{ id: 'videoueberwachung', name: 'Videoüberwachung', purpose: 'Überwachung von Gebäuden und Geländen mittels Videokameras', primary_categories: ['bilddaten', 'beschaeftigtendaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest', categoryInfo: { bilddaten: 'Videoaufzeichnungen von Kameras. Speicherdauer: empfohlen max. 72h (BeschDG-Entwurf). Datenschutzhinweis-Schilder (Art. 13 DSGVO) sind Pflicht. Betriebsrat hat Mitbestimmungsrecht (§ 87 Abs. 1 Nr. 6 BetrVG)' } },
{ id: 'besuchermanagement', name: 'Besuchermanagement', purpose: 'Erfassung und Verwaltung von Besucherdaten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
],
},
@@ -872,10 +875,14 @@ interface ProcessingActivity {
}
interface AISystem {
id: string
name: string
vendor: string
purpose: string
purposes?: string[]
processes_personal_data: boolean
isCustom?: boolean
notes?: string
}
// Helper: find template for an activity ID across all departments
@@ -887,15 +894,14 @@ function findTemplate(departments: ActivityDepartment[], activityId: string): Ac
return null
}
function StepProcessingAndAI({
function StepProcessing({
data,
onChange,
}: {
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[]; aiSystems?: AISystem[] }
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[] }
onChange: (updates: Record<string, unknown>) => void
}) {
const activities: ProcessingActivity[] = (data as any).processingSystems || []
const aiSystems: AISystem[] = (data as any).aiSystems || []
const industry = data.industry || ''
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
@@ -977,26 +983,23 @@ function StepProcessingAndAI({
const deptActivityCount = (dept: ActivityDepartment) =>
dept.activities.filter(a => activeIds.has(a.id)).length
// AI Systems
const addAISystem = () => {
onChange({ aiSystems: [...aiSystems, { name: '', vendor: '', purpose: '', processes_personal_data: false }] })
}
const removeAISystem = (i: number) => {
onChange({ aiSystems: aiSystems.filter((_: AISystem, idx: number) => idx !== i) })
}
const updateAISystem = (i: number, updates: Partial<AISystem>) => {
const updated = [...aiSystems]
updated[i] = { ...updated[i], ...updates }
onChange({ aiSystems: updated })
}
// Render a data category checkbox with info tooltip
const renderCategoryCheckbox = (cat: { id: string; label: string; desc: string; info: string }, activity: ProcessingActivity, variant: 'normal' | 'extra' | 'art9' | 'art9-extra') => {
const renderCategoryCheckbox = (cat: { id: string; label: string; desc: string; info: string }, activity: ProcessingActivity, variant: 'normal' | 'extra' | 'art9' | 'art9-extra', template?: ActivityTemplate | null) => {
const infoText = template?.categoryInfo?.[cat.id] || cat.info
const isInfoExpanded = expandedInfoCat === `${activity.id}-${cat.id}`
const colorClasses = variant.startsWith('art9')
? { check: 'text-red-600 focus:ring-red-500', hover: 'hover:bg-red-100', text: variant === 'art9-extra' ? 'text-gray-500' : 'text-gray-700' }
: { check: 'text-purple-600 focus:ring-purple-500', hover: 'hover:bg-gray-100', text: variant === 'extra' ? 'text-gray-500' : 'text-gray-700' }
// Split info text to highlight retention periods
const aufbewahrungIdx = infoText.indexOf('Aufbewahrung:')
const loeschfristIdx = infoText.indexOf('Löschfrist')
const speicherdauerIdx = infoText.indexOf('Speicherdauer:')
const retentionIdx = [aufbewahrungIdx, loeschfristIdx, speicherdauerIdx].filter(i => i >= 0).sort((a, b) => a - b)[0] ?? -1
const hasRetention = retentionIdx >= 0
const mainText = hasRetention ? infoText.slice(0, retentionIdx).replace(/\.\s*$/, '') : infoText
const retentionText = hasRetention ? infoText.slice(retentionIdx) : ''
return (
<div key={cat.id}>
<label className={`flex items-center gap-2 text-xs p-1.5 rounded ${colorClasses.hover} cursor-pointer`}>
@@ -1006,14 +1009,21 @@ function StepProcessingAndAI({
type="button"
onClick={e => { e.preventDefault(); e.stopPropagation(); setExpandedInfoCat(isInfoExpanded ? null : `${activity.id}-${cat.id}`) }}
className="ml-auto w-4 h-4 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 text-gray-500 text-[10px] font-bold flex-shrink-0"
title={cat.info}
title={infoText}
>
i
</button>
</label>
{isInfoExpanded && (
<div className="ml-7 mt-1 mb-1 px-2 py-1.5 bg-blue-50 border border-blue-100 rounded text-[11px] text-blue-800">
{cat.info}
{hasRetention ? (
<>
<span>{mainText}</span>
<span className="block mt-1 px-1.5 py-0.5 bg-amber-50 border border-amber-200 rounded text-amber-800">
<span className="mr-1">&#128339;</span>{retentionText}
</span>
</>
) : infoText}
</div>
)}
</div>
@@ -1085,7 +1095,7 @@ function StepProcessingAndAI({
<label className="block text-xs font-medium text-gray-600 mb-2">Betroffene Datenkategorien</label>
<div className="grid grid-cols-2 gap-1.5">
{(isCustom ? ALL_DATA_CATEGORIES : primaryCats).map(cat =>
renderCategoryCheckbox(cat, activity, isCustom ? 'normal' : 'normal')
renderCategoryCheckbox(cat, activity, 'normal', template)
)}
</div>
</div>
@@ -1098,7 +1108,7 @@ function StepProcessingAndAI({
</button>
{showingExtra && (
<div className="grid grid-cols-2 gap-1.5 mt-2">
{extraCats.map(cat => renderCategoryCheckbox(cat, activity, 'extra'))}
{extraCats.map(cat => renderCategoryCheckbox(cat, activity, 'extra', template))}
</div>
)}
</div>
@@ -1112,13 +1122,13 @@ function StepProcessingAndAI({
</label>
<div className="grid grid-cols-2 gap-1.5">
{(isCustom ? ALL_SPECIAL_CATEGORIES : relevantArt9).map(cat =>
renderCategoryCheckbox(cat, activity, 'art9')
renderCategoryCheckbox(cat, activity, 'art9', template)
)}
</div>
{/* Show remaining Art. 9 categories if expanded */}
{!isCustom && otherArt9.length > 0 && showingExtra && (
<div className="grid grid-cols-2 gap-1.5 mt-2 pt-2 border-t border-red-100">
{otherArt9.map(cat => renderCategoryCheckbox(cat, activity, 'art9-extra'))}
{otherArt9.map(cat => renderCategoryCheckbox(cat, activity, 'art9-extra', template))}
</div>
)}
</div>
@@ -1257,47 +1267,378 @@ function StepProcessingAndAI({
+ Eigene Verarbeitungstätigkeit hinzufügen
</button>
</div>
</div>
)
}
{/* AI Systems — simplified */}
<div className="border-t border-gray-200 pt-6">
<div className="flex items-center justify-between mb-1">
<h3 className="text-sm font-medium text-gray-700">KI-Systeme</h3>
<button type="button" onClick={addAISystem} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
+ KI-System
</button>
</div>
// =============================================================================
// STEP 7: KI-SYSTEME
// =============================================================================
interface AISystemTemplate {
id: string
name: string
vendor: string
category: string
icon: string
typicalPurposes: string[]
dataWarning?: string
processes_personal_data_likely: boolean
}
const AI_SYSTEM_TEMPLATES: { category: string; icon: string; systems: AISystemTemplate[] }[] = [
{
category: 'Text-KI / Chatbots',
icon: '\uD83D\uDCAC',
systems: [
{ id: 'chatgpt', name: 'ChatGPT', vendor: 'OpenAI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Kundensupport', 'Zusammenfassungen', 'Recherche'], dataWarning: 'Datenverarbeitung in den USA. Eingaben koennen fuer Training verwendet werden (opt-out moeglich).', processes_personal_data_likely: true },
{ id: 'claude', name: 'Claude', vendor: 'Anthropic', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Analyse', 'Zusammenfassungen', 'Code-Review'], dataWarning: 'Datenverarbeitung in den USA. Eingaben werden NICHT fuer Training verwendet.', processes_personal_data_likely: true },
{ id: 'gemini', name: 'Google Gemini', vendor: 'Google', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Datenverarbeitung in den USA/EU je nach Einstellung.', processes_personal_data_likely: true },
{ id: 'perplexity', name: 'Perplexity', vendor: 'Perplexity AI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Websuche mit KI', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Websuche + KI. Eingaben werden verarbeitet.', processes_personal_data_likely: false },
],
},
{
category: 'Office / Produktivitaet',
icon: '\uD83D\uDCCE',
systems: [
{ id: 'copilot365', name: 'Microsoft 365 Copilot', vendor: 'Microsoft', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Praesentationen', 'Excel-Analysen'], dataWarning: 'In M365-Tenant integriert. Daten bleiben im Tenant, aber: KI-Verarbeitung ggf. in den USA.', processes_personal_data_likely: true },
{ id: 'google-workspace-ai', name: 'Google Workspace AI', vendor: 'Google', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Tabellen-Analysen'], dataWarning: 'Duet AI in Docs, Sheets, Gmail. Datenverarbeitung je nach Workspace-Region.', processes_personal_data_likely: true },
{ id: 'notion-ai', name: 'Notion AI', vendor: 'Notion', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Texterstellung', 'Zusammenfassungen', 'Aufgabenverwaltung'], dataWarning: 'Datenverarbeitung in den USA.', processes_personal_data_likely: false },
{ id: 'grammarly', name: 'Grammarly', vendor: 'Grammarly', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Textkorrektur', 'Stiloptimierung', 'Tonalitaet'], dataWarning: 'Textanalyse, Datenverarbeitung in den USA.', processes_personal_data_likely: false },
],
},
{
category: 'Code-Assistenz',
icon: '\uD83D\uDCBB',
systems: [
{ id: 'github-copilot', name: 'GitHub Copilot', vendor: 'Microsoft/GitHub', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Code-Generierung', 'Dokumentation'], dataWarning: 'Code-Vorschlaege basierend auf Kontext. Code-Snippets werden verarbeitet.', processes_personal_data_likely: false },
{ id: 'cursor', name: 'Cursor / Windsurf', vendor: 'Cursor Inc.', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Generierung', 'Refactoring', 'Debugging'], dataWarning: 'KI-Code-Editor. Code wird an KI-Backend uebermittelt.', processes_personal_data_likely: false },
{ id: 'codewhisperer', name: 'Amazon CodeWhisperer', vendor: 'AWS', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Sicherheits-Scans'], dataWarning: 'Code-Vorschlaege. Opt-out fuer Code-Sharing moeglich.', processes_personal_data_likely: false },
],
},
{
category: 'Bildgenerierung',
icon: '\uD83C\uDFA8',
systems: [
{ id: 'dalle', name: 'DALL-E / ChatGPT Bildgenerierung', vendor: 'OpenAI', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Marketing-Material', 'Illustrationen'], dataWarning: 'Bildgenerierung. Prompts werden verarbeitet.', processes_personal_data_likely: false },
{ id: 'midjourney', name: 'Midjourney', vendor: 'Midjourney Inc.', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Design-Konzepte', 'Illustrationen'], dataWarning: 'Bildgenerierung via Discord. Prompts sind oeffentlich sichtbar (ausser Pro-Plan).', processes_personal_data_likely: false },
{ id: 'firefly', name: 'Adobe Firefly', vendor: 'Adobe', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Bildbearbeitung', 'Design'], dataWarning: 'In Creative Cloud integriert. Trainiert auf lizenzierten Inhalten.', processes_personal_data_likely: false },
],
},
{
category: 'Uebersetzung / Sprache',
icon: '\uD83C\uDF10',
systems: [
{ id: 'deepl', name: 'DeepL', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Uebersetzung', 'Dokumentenuebersetzung'], dataWarning: 'Deutscher Anbieter, Server in EU. DeepL Pro: Texte werden NICHT gespeichert.', processes_personal_data_likely: false },
{ id: 'deepl-write', name: 'DeepL Write', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Textoptimierung', 'Stilverbesserung'], dataWarning: 'Deutscher Anbieter, Server in EU. Gleiche Datenschutz-Bedingungen wie DeepL.', processes_personal_data_likely: false },
],
},
{
category: 'CRM / Sales KI',
icon: '\uD83D\uDCCA',
systems: [
{ id: 'salesforce-einstein', name: 'Salesforce Einstein', vendor: 'Salesforce', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['Lead-Scoring', 'Prognosen', 'Empfehlungen'], dataWarning: 'In Salesforce integriert. Verarbeitet CRM-Daten.', processes_personal_data_likely: true },
{ id: 'hubspot-ai', name: 'HubSpot AI', vendor: 'HubSpot', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['E-Mail-Generierung', 'Lead-Scoring', 'Content-Erstellung'], dataWarning: 'KI-Features in HubSpot CRM. Datenverarbeitung in USA/EU.', processes_personal_data_likely: true },
],
},
{
category: 'Interne / Eigene Systeme',
icon: '\uD83C\uDFE2',
systems: [
{ id: 'internal-ai', name: 'Eigenes KI-System', vendor: 'Intern', category: 'Interne / Eigene Systeme', icon: '\uD83C\uDFE2', typicalPurposes: ['Interne Analyse', 'Automatisierung', 'Prozessoptimierung'], dataWarning: undefined, processes_personal_data_likely: false },
],
},
]
function StepAISystems({
data,
onChange,
}: {
data: Partial<CompanyProfile> & { aiSystems?: AISystem[] }
onChange: (updates: Record<string, unknown>) => void
}) {
const aiSystems: AISystem[] = (data as any).aiSystems || []
const [expandedSystem, setExpandedSystem] = useState<string | null>(null)
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const activeIds = new Set(aiSystems.map(a => a.id))
const toggleTemplateSystem = (template: AISystemTemplate) => {
if (activeIds.has(template.id)) {
onChange({ aiSystems: aiSystems.filter(a => a.id !== template.id) })
if (expandedSystem === template.id) setExpandedSystem(null)
} else {
const newSystem: AISystem = {
id: template.id,
name: template.name,
vendor: template.vendor,
purpose: template.typicalPurposes.join(', '),
purposes: [],
processes_personal_data: template.processes_personal_data_likely,
isCustom: false,
}
onChange({ aiSystems: [...aiSystems, newSystem] })
setExpandedSystem(template.id)
}
}
const updateAISystem = (id: string, updates: Partial<AISystem>) => {
onChange({
aiSystems: aiSystems.map(a => a.id === id ? { ...a, ...updates } : a),
})
}
const togglePurpose = (systemId: string, purpose: string) => {
const system = aiSystems.find(a => a.id === systemId)
if (!system) return
const purposes = system.purposes || []
const updated = purposes.includes(purpose)
? purposes.filter(p => p !== purpose)
: [...purposes, purpose]
updateAISystem(systemId, { purposes: updated, purpose: updated.join(', ') })
}
const addCustomSystem = () => {
const id = `custom_ai_${Date.now()}`
const newSystem: AISystem = {
id,
name: '',
vendor: '',
purpose: '',
processes_personal_data: false,
isCustom: true,
}
onChange({ aiSystems: [...aiSystems, newSystem] })
setExpandedSystem(id)
}
const removeSystem = (id: string) => {
onChange({ aiSystems: aiSystems.filter(a => a.id !== id) })
if (expandedSystem === id) setExpandedSystem(null)
}
const toggleCategoryCollapse = (category: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev)
if (next.has(category)) next.delete(category); else next.add(category)
return next
})
}
const categoryActiveCount = (systems: AISystemTemplate[]) =>
systems.filter(s => activeIds.has(s.id)).length
return (
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-gray-700 mb-1">KI-Systeme im Einsatz</h3>
<p className="text-xs text-gray-500 mb-4">
Welche KI-Systeme setzen Sie ein? Die Risikoeinstufung nach AI Act ermittelt unser Tool automatisch.
Waehlen Sie die KI-Systeme aus, die in Ihrem Unternehmen eingesetzt werden. Dies dient der Erfassung fuer den EU AI Act und die DSGVO-Dokumentation.
</p>
</div>
{aiSystems.length === 0 && (
<div className="text-center py-6 text-gray-400 border-2 border-dashed rounded-lg">
Noch keine KI-Systeme falls Sie keine einsetzen, einfach leer lassen
{/* Template categories */}
<div className="space-y-4">
{AI_SYSTEM_TEMPLATES.map(group => {
const isCollapsed = collapsedCategories.has(group.category)
const activeCount = categoryActiveCount(group.systems)
return (
<div key={group.category} className="border border-gray-200 rounded-lg overflow-hidden">
{/* Category header */}
<button
type="button"
onClick={() => toggleCategoryCollapse(group.category)}
className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
>
<span className="text-base">{group.icon}</span>
<span className="text-sm font-medium text-gray-900 flex-1">{group.category}</span>
{activeCount > 0 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
{activeCount} aktiv
</span>
)}
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Systems in category */}
{!isCollapsed && (
<div className="p-3 space-y-2">
{group.systems.map(template => {
const isActive = activeIds.has(template.id)
const system = aiSystems.find(a => a.id === template.id)
const isExpanded = expandedSystem === template.id
return (
<div key={template.id}>
<div
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'
}`}
onClick={() => {
if (!isActive) {
toggleTemplateSystem(template)
} else {
setExpandedSystem(isExpanded ? null : template.id)
}
}}
>
<input
type="checkbox"
checked={isActive}
onChange={e => { e.stopPropagation(); toggleTemplateSystem(template) }}
className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900">{template.name}</div>
<p className="text-xs text-gray-500">{template.vendor}</p>
</div>
{isActive && (
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{/* Detail panel */}
{isActive && isExpanded && system && (
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
{/* Purposes as chips */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">Einsatzzweck</label>
<div className="flex flex-wrap gap-2">
{template.typicalPurposes.map(purpose => (
<button
key={purpose}
type="button"
onClick={() => togglePurpose(template.id, purpose)}
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${
(system.purposes || []).includes(purpose)
? 'bg-purple-100 border-purple-300 text-purple-700'
: 'bg-white border-gray-200 text-gray-600 hover:border-purple-200'
}`}
>
{purpose}
</button>
))}
</div>
<input
type="text"
value={system.notes || ''}
onChange={e => updateAISystem(template.id, { notes: e.target.value })}
placeholder="Weitere Einsatzzwecke / Anmerkungen..."
className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
{/* Data warning */}
{template.dataWarning && (
<div className={`flex items-start gap-2 px-3 py-2 rounded-lg ${
template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') || template.dataWarning.includes('NICHT')
? 'bg-blue-50 border border-blue-200'
: 'bg-amber-50 border border-amber-200'
}`}>
<span className="text-sm mt-0.5">{template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') ? '\u2139\uFE0F' : '\u26A0\uFE0F'}</span>
<span className="text-xs text-gray-800">{template.dataWarning}</span>
</div>
)}
{/* Personal data checkbox */}
<label className="flex items-center gap-2 px-1 cursor-pointer">
<input
type="checkbox"
checked={system.processes_personal_data}
onChange={e => updateAISystem(template.id, { processes_personal_data: e.target.checked })}
className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
</label>
<button type="button" onClick={() => removeSystem(template.id)} className="text-xs text-red-500 hover:text-red-700">
KI-System entfernen
</button>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Custom AI systems */}
{aiSystems.filter(a => a.isCustom).map(system => (
<div key={system.id} className="mt-2">
<div
className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer"
onClick={() => setExpandedSystem(expandedSystem === system.id ? null : system.id)}
>
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900">{system.name || 'Neues KI-System'}</span>
{system.vendor && <span className="text-xs text-gray-500 ml-2">({system.vendor})</span>}
</div>
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedSystem === system.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
)}
<div className="space-y-3">
{aiSystems.map((ai: AISystem, i: number) => (
<div key={i} className="border border-gray-200 rounded-lg p-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-gray-400">KI-System {i + 1}</span>
<button type="button" onClick={() => removeAISystem(i)} className="text-red-400 hover:text-red-600 text-xs">Entfernen</button>
</div>
{expandedSystem === system.id && (
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
<div className="grid grid-cols-2 gap-3">
<input type="text" value={ai.name} onChange={e => updateAISystem(i, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={ai.vendor} onChange={e => updateAISystem(i, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={system.name} onChange={e => updateAISystem(system.id, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<input type="text" value={system.vendor} onChange={e => updateAISystem(system.id, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<input type="text" value={ai.purpose} onChange={e => updateAISystem(i, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<label className="flex items-center gap-2 px-1">
<input type="text" value={system.purpose} onChange={e => updateAISystem(system.id, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<label className="flex items-center gap-2 px-1 cursor-pointer">
<input
type="checkbox"
checked={ai.processes_personal_data}
onChange={e => updateAISystem(i, { processes_personal_data: e.target.checked })}
checked={system.processes_personal_data}
onChange={e => updateAISystem(system.id, { processes_personal_data: e.target.checked })}
className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500"
/>
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
</label>
<button type="button" onClick={() => removeSystem(system.id)} className="text-xs text-red-500 hover:text-red-700">
KI-System entfernen
</button>
</div>
))}
)}
</div>
))}
{/* Add custom system button */}
<button
type="button"
onClick={addCustomSystem}
className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors"
>
+ Eigenes KI-System hinzufuegen
</button>
{/* AI Act module link */}
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<span className="text-lg">{'\u2139\uFE0F'}</span>
<div>
<h4 className="text-sm font-medium text-blue-900 mb-1">AI Act Risikoeinstufung</h4>
<p className="text-xs text-blue-800 mb-3">
Die detaillierte Risikoeinstufung Ihrer KI-Systeme nach EU AI Act (verboten / hochriskant / begrenzt / minimal) erfolgt automatisch im AI-Act-Modul.
</p>
<a
href="/sdk/ai-act"
className="inline-flex items-center gap-1 text-sm font-medium text-blue-700 hover:text-blue-900"
>
Zum AI-Act-Modul
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</a>
</div>
</div>
</div>
</div>
@@ -1305,7 +1646,7 @@ function StepProcessingAndAI({
}
// =============================================================================
// STEP 7: RECHTLICHER RAHMEN
// STEP 8: RECHTLICHER RAHMEN
// =============================================================================
function StepLegalFramework({
@@ -2032,7 +2373,7 @@ export default function CompanyProfilePage() {
if (!cancelled && state.companyProfile) {
setFormData(state.companyProfile)
if (state.companyProfile.isComplete) {
setCurrentStep(7)
setCurrentStep(8)
}
}
}
@@ -2142,10 +2483,10 @@ export default function CompanyProfilePage() {
const handleNext = () => {
if (currentStep < lastStep) {
// Skip step 8 if not a machine builder
// Skip step 9 if not a machine builder
const nextStep = currentStep + 1
if (nextStep === 8 && !showMachineBuilderStep) {
// Complete profile (was step 7, last step for non-machine-builders)
if (nextStep === 9 && !showMachineBuilderStep) {
// Complete profile (was step 8, last step for non-machine-builders)
completeAndSaveProfile()
return
}
@@ -2249,10 +2590,12 @@ export default function CompanyProfilePage() {
case 5:
return true
case 6:
return true // Systems & AI step is optional
return true // Processing step is optional
case 7:
return true // Legal framework step is optional
return true // AI systems step is optional
case 8:
return true // Legal framework step is optional
case 9:
// Machine builder step: require at least product description
return (formData.machineBuilder?.productDescription?.length || 0) > 0
default:
@@ -2260,7 +2603,7 @@ export default function CompanyProfilePage() {
}
}
const isLastStep = currentStep === lastStep || (currentStep === 7 && !showMachineBuilderStep)
const isLastStep = currentStep === lastStep || (currentStep === 8 && !showMachineBuilderStep)
return (
<div className="min-h-screen bg-gray-50 py-8">
@@ -2343,9 +2686,10 @@ export default function CompanyProfilePage() {
{currentStep === 3 && <StepCompanySize data={formData} onChange={updateFormData} />}
{currentStep === 4 && <StepLocations data={formData} onChange={updateFormData} />}
{currentStep === 5 && <StepDataProtection data={formData} onChange={updateFormData} />}
{currentStep === 6 && <StepProcessingAndAI data={formData} onChange={updateFormData} />}
{currentStep === 7 && <StepLegalFramework data={formData} onChange={updateFormData} />}
{currentStep === 8 && showMachineBuilderStep && <StepMachineBuilder data={formData} onChange={updateFormData} />}
{currentStep === 6 && <StepProcessing data={formData} onChange={updateFormData} />}
{currentStep === 7 && <StepAISystems data={formData} onChange={updateFormData} />}
{currentStep === 8 && <StepLegalFramework data={formData} onChange={updateFormData} />}
{currentStep === 9 && showMachineBuilderStep && <StepMachineBuilder data={formData} onChange={updateFormData} />}
{/* Navigation */}
<div className="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">

View File

@@ -0,0 +1,460 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Search, BookOpen, AlertTriangle, Shield, Scale, Handshake,
Briefcase, MessageCircle, Building2, Database, ChevronRight,
ArrowLeft, ExternalLink, Tag, Clock, Globe, Gavel,
} from 'lucide-react'
import type { WikiCategory, WikiArticle, WikiSearchResult } from '@/lib/sdk/types'
// =============================================================================
// SIMPLE MARKDOWN RENDERER
// =============================================================================
function renderMarkdown(md: string): string {
let html = md
// Escape HTML
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Code blocks (``` ... ```)
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_match, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
// Tables (must be before other block elements)
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_match, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) => `<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) => `<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
// Headers
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
// Paragraphs (lines that aren't already HTML)
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return html
}
// =============================================================================
// ICON MAP
// =============================================================================
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Database,
Shield,
AlertTriangle,
Scale,
Handshake,
Briefcase,
MessageCircle,
Building2,
Clock,
Globe,
Gavel,
}
function CategoryIcon({ icon, className }: { icon: string; className?: string }) {
const Icon = ICON_MAP[icon] || BookOpen
return <Icon className={className} />
}
// =============================================================================
// RELEVANCE BADGE
// =============================================================================
function RelevanceBadge({ relevance }: { relevance: string }) {
const config = {
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch' },
important: { bg: 'bg-amber-100 text-amber-800', label: 'Wichtig' },
info: { bg: 'bg-blue-100 text-blue-800', label: 'Info' },
}[relevance] || { bg: 'bg-gray-100 text-gray-600', label: relevance }
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
{config.label}
</span>
)
}
// =============================================================================
// WIKI PAGE
// =============================================================================
export default function WikiPage() {
const [categories, setCategories] = useState<WikiCategory[]>([])
const [articles, setArticles] = useState<WikiArticle[]>([])
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [selectedArticle, setSelectedArticle] = useState<WikiArticle | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<WikiSearchResult[] | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Load categories on mount
useEffect(() => {
async function load() {
try {
const res = await fetch('/api/sdk/v1/wiki?endpoint=categories')
if (!res.ok) throw new Error('Failed to load categories')
const data = await res.json()
const cats: WikiCategory[] = (data.categories || []).map((c: Record<string, unknown>) => ({
id: c.id,
name: c.name,
description: c.description || '',
icon: c.icon || '',
sortOrder: c.sort_order ?? 0,
articleCount: c.article_count ?? 0,
}))
setCategories(cats)
// Load all articles
const artRes = await fetch('/api/sdk/v1/wiki?endpoint=articles')
if (artRes.ok) {
const artData = await artRes.json()
setArticles((artData.articles || []).map((a: Record<string, unknown>) => ({
id: a.id,
categoryId: a.category_id,
categoryName: a.category_name,
title: a.title,
summary: a.summary,
content: a.content,
legalRefs: a.legal_refs || [],
tags: a.tags || [],
relevance: a.relevance || 'info',
sourceUrls: a.source_urls || [],
version: a.version || 1,
updatedAt: a.updated_at || '',
})))
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
load()
}, [])
// Search handler
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query)
if (query.length < 2) {
setSearchResults(null)
return
}
try {
const res = await fetch(`/api/sdk/v1/wiki?endpoint=search&q=${encodeURIComponent(query)}`)
if (res.ok) {
const data = await res.json()
setSearchResults((data.results || []).map((r: Record<string, unknown>) => ({
id: r.id,
title: r.title,
summary: r.summary,
categoryName: r.category_name,
relevance: r.relevance || 'info',
highlight: r.highlight || '',
})))
}
} catch {
// silently fail search
}
}, [])
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery.length >= 2) handleSearch(searchQuery)
}, 300)
return () => clearTimeout(timer)
}, [searchQuery, handleSearch])
// Filtered articles for selected category
const filteredArticles = useMemo(() => {
if (!selectedCategory) return articles
return articles.filter(a => a.categoryId === selectedCategory)
}, [articles, selectedCategory])
// Select article from search result
const selectFromSearch = (id: string) => {
const article = articles.find(a => a.id === id)
if (article) {
setSelectedArticle(article)
setSelectedCategory(article.categoryId)
setSearchResults(null)
setSearchQuery('')
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
</div>
)
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800 text-sm">
{error}
</div>
</div>
)
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BookOpen className="w-6 h-6 text-purple-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">Compliance Wiki</h1>
<p className="text-xs text-gray-500">Interne Wissensbasis {articles.length} Artikel in {categories.length} Kategorien</p>
</div>
</div>
{/* Search */}
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Wiki durchsuchen..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
{/* Search results dropdown */}
{searchResults && searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto">
{searchResults.map(r => (
<button
key={r.id}
onClick={() => selectFromSearch(r.id)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-0"
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-gray-900">{r.title}</span>
<RelevanceBadge relevance={r.relevance} />
</div>
<p className="text-xs text-gray-500">{r.categoryName}</p>
{r.highlight && (
<p className="text-xs text-gray-600 mt-1 line-clamp-2">{r.highlight.replace(/\*\*/g, '')}</p>
)}
</button>
))}
</div>
)}
{searchResults && searchResults.length === 0 && searchQuery.length >= 2 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 px-4 py-3 text-sm text-gray-500">
Keine Ergebnisse fuer &quot;{searchQuery}&quot;
</div>
)}
</div>
</div>
</div>
{/* Content: Two columns */}
<div className="flex flex-1 overflow-hidden">
{/* Left: Categories */}
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
<div className="p-3">
<button
onClick={() => { setSelectedCategory(null); setSelectedArticle(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors mb-1 ${
!selectedCategory ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<BookOpen className="w-4 h-4" />
<span>Alle Artikel</span>
<span className="ml-auto text-xs text-gray-400">{articles.length}</span>
</button>
<div className="mt-2 space-y-0.5">
{categories.map(cat => (
<button
key={cat.id}
onClick={() => { setSelectedCategory(cat.id); setSelectedArticle(null) }}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
selectedCategory === cat.id ? 'bg-purple-100 text-purple-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<CategoryIcon icon={cat.icon} className="w-4 h-4 flex-shrink-0" />
<span className="truncate text-left">{cat.name}</span>
<span className="ml-auto text-xs text-gray-400 flex-shrink-0">{cat.articleCount}</span>
</button>
))}
</div>
</div>
</div>
{/* Right: Article list or detail */}
<div className="flex-1 overflow-y-auto">
{selectedArticle ? (
/* Article detail view */
<div className="max-w-3xl mx-auto p-6">
<button
onClick={() => setSelectedArticle(null)}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zur Uebersicht
</button>
<div className="flex items-start gap-3 mb-4">
<RelevanceBadge relevance={selectedArticle.relevance} />
<span className="text-xs text-gray-400">
{selectedArticle.categoryName} &middot; v{selectedArticle.version}
</span>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">
{selectedArticle.title}
</h2>
<p className="text-sm text-gray-600 mb-6">{selectedArticle.summary}</p>
{/* Content (rendered markdown) */}
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedArticle.content) }}
/>
{/* Legal References */}
{selectedArticle.legalRefs.length > 0 && (
<div className="mt-6 pt-4 border-t border-gray-200">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Rechtsreferenzen</h4>
<div className="flex flex-wrap gap-1.5">
{selectedArticle.legalRefs.map(ref => (
<span key={ref} className="inline-flex items-center gap-1 px-2 py-1 bg-purple-50 text-purple-700 rounded text-xs">
<Scale className="w-3 h-3" />
{ref}
</span>
))}
</div>
</div>
)}
{/* Tags */}
{selectedArticle.tags.length > 0 && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Tags</h4>
<div className="flex flex-wrap gap-1.5">
{selectedArticle.tags.map(tag => (
<span key={tag} className="inline-flex items-center gap-1 px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">
<Tag className="w-3 h-3" />
{tag}
</span>
))}
</div>
</div>
)}
{/* Source URLs */}
{selectedArticle.sourceUrls.length > 0 && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">Quellen</h4>
<div className="space-y-1">
{selectedArticle.sourceUrls.map(url => (
<div key={url} className="flex items-center gap-1 text-xs text-blue-600">
<ExternalLink className="w-3 h-3" />
{url.startsWith('http') ? (
<a href={url} target="_blank" rel="noopener noreferrer" className="hover:underline">{url}</a>
) : (
<span>{url}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
) : (
/* Article list view */
<div className="p-6">
<h2 className="text-base font-semibold text-gray-900 mb-4">
{selectedCategory
? categories.find(c => c.id === selectedCategory)?.name || 'Artikel'
: 'Alle Artikel'
}
<span className="ml-2 text-sm font-normal text-gray-400">
({filteredArticles.length})
</span>
</h2>
{selectedCategory && (
<p className="text-sm text-gray-500 mb-4">
{categories.find(c => c.id === selectedCategory)?.description}
</p>
)}
<div className="space-y-3">
{filteredArticles.map(article => (
<button
key={article.id}
onClick={() => setSelectedArticle(article)}
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<RelevanceBadge relevance={article.relevance} />
{!selectedCategory && (
<span className="text-xs text-gray-400">{article.categoryName}</span>
)}
</div>
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
{article.title}
</h3>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{article.summary}</p>
{article.legalRefs.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{article.legalRefs.slice(0, 3).map(ref => (
<span key={ref} className="text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
{ref}
</span>
))}
{article.legalRefs.length > 3 && (
<span className="text-xs text-gray-400">+{article.legalRefs.length - 3}</span>
)}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1" />
</div>
</button>
))}
{filteredArticles.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm">
Keine Artikel in dieser Kategorie.
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -760,6 +760,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/wiki"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="Compliance Wiki"
isActive={pathname?.startsWith('/sdk/wiki')}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/api-docs"
icon={

View File

@@ -5,7 +5,7 @@
* retry logic, and optimistic locking support.
*/
import { SDKState, CheckpointStatus, ProjectInfo } from './types'
import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } from './types'
// =============================================================================
// TYPES
@@ -730,6 +730,110 @@ export class SDKApiClient {
)
}
// ===========================================================================
// WIKI (read-only knowledge base)
// ===========================================================================
/**
* List all wiki categories with article counts
*/
async listWikiCategories(): Promise<WikiCategory[]> {
const data = await this.fetchWithRetry<{ categories: Array<{
id: string; name: string; description: string; icon: string;
sort_order: number; article_count: number
}> }>(
`${this.baseUrl}/wiki?endpoint=categories`,
{ method: 'GET' }
)
return (data.categories || []).map(c => ({
id: c.id,
name: c.name,
description: c.description,
icon: c.icon,
sortOrder: c.sort_order,
articleCount: c.article_count,
}))
}
/**
* List wiki articles, optionally filtered by category
*/
async listWikiArticles(categoryId?: string): Promise<WikiArticle[]> {
const params = new URLSearchParams({ endpoint: 'articles' })
if (categoryId) params.set('category_id', categoryId)
const data = await this.fetchWithRetry<{ articles: Array<{
id: string; category_id: string; category_name: string; title: string;
summary: string; content: string; legal_refs: string[]; tags: string[];
relevance: string; source_urls: string[]; version: number; updated_at: string
}> }>(
`${this.baseUrl}/wiki?${params.toString()}`,
{ method: 'GET' }
)
return (data.articles || []).map(a => ({
id: a.id,
categoryId: a.category_id,
categoryName: a.category_name,
title: a.title,
summary: a.summary,
content: a.content,
legalRefs: a.legal_refs || [],
tags: a.tags || [],
relevance: a.relevance as WikiArticle['relevance'],
sourceUrls: a.source_urls || [],
version: a.version,
updatedAt: a.updated_at,
}))
}
/**
* Get a single wiki article by ID
*/
async getWikiArticle(id: string): Promise<WikiArticle> {
const data = await this.fetchWithRetry<{
id: string; category_id: string; category_name: string; title: string;
summary: string; content: string; legal_refs: string[]; tags: string[];
relevance: string; source_urls: string[]; version: number; updated_at: string
}>(
`${this.baseUrl}/wiki?endpoint=article&id=${encodeURIComponent(id)}`,
{ method: 'GET' }
)
return {
id: data.id,
categoryId: data.category_id,
categoryName: data.category_name,
title: data.title,
summary: data.summary,
content: data.content,
legalRefs: data.legal_refs || [],
tags: data.tags || [],
relevance: data.relevance as WikiArticle['relevance'],
sourceUrls: data.source_urls || [],
version: data.version,
updatedAt: data.updated_at,
}
}
/**
* Full-text search across wiki articles
*/
async searchWiki(query: string): Promise<WikiSearchResult[]> {
const data = await this.fetchWithRetry<{ results: Array<{
id: string; title: string; summary: string; category_name: string;
relevance: string; highlight: string
}> }>(
`${this.baseUrl}/wiki?endpoint=search&q=${encodeURIComponent(query)}`,
{ method: 'GET' }
)
return (data.results || []).map(r => ({
id: r.id,
title: r.title,
summary: r.summary,
categoryName: r.category_name,
relevance: r.relevance,
highlight: r.highlight,
}))
}
/**
* Health check
*/

View File

@@ -2398,3 +2398,40 @@ export const DSFA_CATEGORY_LABELS: Record<DSFACategory, string> = {
process: 'Prozessschritte',
criteria: 'Kriterien',
}
// =============================================================================
// COMPLIANCE WIKI
// =============================================================================
export interface WikiCategory {
id: string
name: string
description: string
icon: string
sortOrder: number
articleCount: number
}
export interface WikiArticle {
id: string
categoryId: string
categoryName: string
title: string
summary: string
content: string
legalRefs: string[]
tags: string[]
relevance: 'critical' | 'important' | 'info'
sourceUrls: string[]
version: number
updatedAt: string
}
export interface WikiSearchResult {
id: string
title: string
summary: string
categoryName: string
relevance: string
highlight: string
}

View File

@@ -32,6 +32,7 @@ from .incident_routes import router as incident_router
from .change_request_routes import router as change_request_router
from .generation_routes import router as generation_router
from .project_routes import router as project_router
from .wiki_routes import router as wiki_router
# Include sub-routers
router.include_router(audit_router)
@@ -65,6 +66,7 @@ router.include_router(incident_router)
router.include_router(change_request_router)
router.include_router(generation_router)
router.include_router(project_router)
router.include_router(wiki_router)
__all__ = [
"router",
@@ -98,4 +100,5 @@ __all__ = [
"change_request_router",
"generation_router",
"project_router",
"wiki_router",
]

View File

@@ -0,0 +1,218 @@
"""
FastAPI routes for Compliance Wiki (read-only knowledge base).
Endpoints:
- GET /v1/wiki/categories → All categories with article counts
- GET /v1/wiki/articles → All articles (optional category filter)
- GET /v1/wiki/articles/{id} → Single article
- GET /v1/wiki/search → Full-text search (PostgreSQL tsvector)
"""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from database import SessionLocal
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/wiki", tags=["wiki"])
# =============================================================================
# RESPONSE MODELS
# =============================================================================
class WikiCategoryResponse(BaseModel):
id: str
name: str
description: str
icon: str
sort_order: int
article_count: int
class WikiArticleResponse(BaseModel):
id: str
category_id: str
category_name: str
title: str
summary: str
content: str
legal_refs: list[str]
tags: list[str]
relevance: str
source_urls: list[str]
version: int
updated_at: str
class WikiSearchResultResponse(BaseModel):
id: str
title: str
summary: str
category_name: str
relevance: str
highlight: str
# =============================================================================
# HELPERS
# =============================================================================
def _article_row_to_response(row) -> dict:
"""Convert a DB row to WikiArticleResponse dict."""
return {
"id": row.id,
"category_id": row.category_id,
"category_name": getattr(row, "category_name", ""),
"title": row.title,
"summary": row.summary,
"content": row.content,
"legal_refs": list(row.legal_refs) if row.legal_refs else [],
"tags": list(row.tags) if row.tags else [],
"relevance": row.relevance or "info",
"source_urls": list(row.source_urls) if row.source_urls else [],
"version": row.version or 1,
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("/categories")
async def list_categories():
"""List all wiki categories with article counts."""
db = SessionLocal()
try:
result = db.execute(text("""
SELECT c.id, c.name, c.description, c.icon, c.sort_order,
COUNT(a.id) AS article_count
FROM compliance_wiki_categories c
LEFT JOIN compliance_wiki_articles a ON a.category_id = c.id
GROUP BY c.id, c.name, c.description, c.icon, c.sort_order
ORDER BY c.sort_order
"""))
rows = result.fetchall()
return {
"categories": [
{
"id": row.id,
"name": row.name,
"description": row.description or "",
"icon": row.icon or "",
"sort_order": row.sort_order or 0,
"article_count": row.article_count or 0,
}
for row in rows
]
}
finally:
db.close()
@router.get("/articles")
async def list_articles(
category_id: Optional[str] = Query(None, description="Filter by category"),
):
"""List all wiki articles, optionally filtered by category."""
db = SessionLocal()
try:
if category_id:
result = db.execute(text("""
SELECT a.*, c.name AS category_name
FROM compliance_wiki_articles a
JOIN compliance_wiki_categories c ON c.id = a.category_id
WHERE a.category_id = :category_id
ORDER BY
CASE a.relevance
WHEN 'critical' THEN 0
WHEN 'important' THEN 1
ELSE 2
END,
a.title
"""), {"category_id": category_id})
else:
result = db.execute(text("""
SELECT a.*, c.name AS category_name
FROM compliance_wiki_articles a
JOIN compliance_wiki_categories c ON c.id = a.category_id
ORDER BY c.sort_order,
CASE a.relevance
WHEN 'critical' THEN 0
WHEN 'important' THEN 1
ELSE 2
END,
a.title
"""))
rows = result.fetchall()
return {
"articles": [_article_row_to_response(row) for row in rows],
"total": len(rows),
}
finally:
db.close()
@router.get("/articles/{article_id}")
async def get_article(article_id: str):
"""Get a single wiki article by ID."""
db = SessionLocal()
try:
result = db.execute(text("""
SELECT a.*, c.name AS category_name
FROM compliance_wiki_articles a
JOIN compliance_wiki_categories c ON c.id = a.category_id
WHERE a.id = :article_id
"""), {"article_id": article_id})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Article not found")
return _article_row_to_response(row)
finally:
db.close()
@router.get("/search")
async def search_wiki(
q: str = Query(..., min_length=2, description="Search query"),
):
"""Full-text search across wiki articles using PostgreSQL tsvector."""
db = SessionLocal()
try:
result = db.execute(text("""
SELECT a.id, a.title, a.summary, a.relevance,
c.name AS category_name,
ts_headline('german', a.content, plainto_tsquery('german', :query),
'MaxWords=40, MinWords=20, StartSel=**, StopSel=**') AS highlight
FROM compliance_wiki_articles a
JOIN compliance_wiki_categories c ON c.id = a.category_id
WHERE to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content)
@@ plainto_tsquery('german', :query)
ORDER BY
ts_rank(to_tsvector('german', a.title || ' ' || a.summary || ' ' || a.content),
plainto_tsquery('german', :query)) DESC
LIMIT 20
"""), {"query": q})
rows = result.fetchall()
return {
"results": [
{
"id": row.id,
"title": row.title,
"summary": row.summary,
"category_name": row.category_name,
"relevance": row.relevance or "info",
"highlight": row.highlight or "",
}
for row in rows
],
"total": len(rows),
"query": q,
}
finally:
db.close()

View File

@@ -0,0 +1,465 @@
-- Migration 040: Compliance Wiki (Strukturierte Wissensbasis)
-- Interne Admin-Wissensbasis fuer DSGVO/Compliance-Fachwissen.
-- System-Eintraege (read-only), kein tenant_id — globale Daten.
-- =============================================================================
-- 1. Wiki-Kategorien
-- =============================================================================
CREATE TABLE IF NOT EXISTS compliance_wiki_categories (
id VARCHAR(100) PRIMARY KEY,
name VARCHAR(300) NOT NULL,
description TEXT DEFAULT '',
icon VARCHAR(50) DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- 2. Wiki-Artikel
-- =============================================================================
CREATE TABLE IF NOT EXISTS compliance_wiki_articles (
id VARCHAR(100) PRIMARY KEY,
category_id VARCHAR(100) NOT NULL REFERENCES compliance_wiki_categories(id),
title VARCHAR(500) NOT NULL,
summary TEXT NOT NULL,
content TEXT NOT NULL,
legal_refs TEXT[] DEFAULT '{}',
tags TEXT[] DEFAULT '{}',
relevance VARCHAR(20) DEFAULT 'info',
source_urls TEXT[] DEFAULT '{}',
version INTEGER DEFAULT 1,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wiki_articles_category ON compliance_wiki_articles(category_id);
CREATE INDEX IF NOT EXISTS idx_wiki_articles_tags ON compliance_wiki_articles USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_wiki_articles_search ON compliance_wiki_articles
USING GIN(to_tsvector('german', title || ' ' || summary || ' ' || content));
-- =============================================================================
-- 3. Seed-Daten: Kategorien
-- =============================================================================
INSERT INTO compliance_wiki_categories (id, name, description, icon, sort_order) VALUES
('datenkategorien', 'Datenkategorien & Abgrenzung', 'Welche personenbezogenen Daten gibt es und wie grenzt man sie voneinander ab?', 'Database', 10),
('dsgvo-grundlagen', 'DSGVO-Grundlagen', 'Grundlegende Konzepte der Datenschutz-Grundverordnung', 'Shield', 20),
('art9-besondere', 'Besondere Kategorien (Art. 9)', 'Besonders schuetzenswerte Daten nach Art. 9 DSGVO', 'AlertTriangle', 30),
('rechtsgrundlagen', 'Rechtsgrundlagen', 'Die sechs Rechtsgrundlagen fuer die Datenverarbeitung', 'Scale', 40),
('avv-dienstleister', 'Auftragsverarbeitung (AVV)', 'Regeln fuer externe Dienstleister, die Daten verarbeiten', 'Handshake', 50),
('arbeitsrecht', 'Arbeitsrecht & Compliance', 'Datenschutz im Arbeitsverhaeltnis', 'Briefcase', 60),
('hinschg', 'Hinweisgeberschutz (HinSchG)', 'Pflichten zum Schutz von Hinweisgebern', 'MessageCircle', 70),
('branchenspezifisch', 'Branchenspezifisches', 'Besonderheiten einzelner Branchen', 'Building2', 80)
ON CONFLICT (id) DO NOTHING;
-- =============================================================================
-- 4. Seed-Daten: Artikel
-- =============================================================================
-- 1. Gesundheitsdaten — Abgrenzung
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('gesundheitsdaten-abgrenzung', 'datenkategorien',
'Gesundheitsdaten — Was zaehlt dazu und was nicht?',
'Nicht alles, was mit Gesundheit zu tun hat, ist automatisch ein Gesundheitsdatum im Sinne der DSGVO. Die Abgrenzung ist in der Praxis wichtig.',
'## Ueberblick
Der Name der Krankenkasse (z.B. "AOK Bayern", "TK") ist **kein Gesundheitsdatum** nach Art. 9 DSGVO. Er verraet nichts ueber den Gesundheitszustand einer Person — jeder Arbeitnehmer hat eine Krankenkasse, unabhaengig davon ob er gesund oder krank ist.
## Was SIND Gesundheitsdaten?
- Diagnosen, Krankheitsbilder, Befunde
- Krankmeldungen (AU-Bescheinigungen) mit Diagnose
- Schwerbehindertenausweis / Grad der Behinderung
- Medikamenteneinnahme
- Ergebnisse von Eignungsuntersuchungen
## Was sind KEINE Gesundheitsdaten?
- Name der Krankenkasse (reine Verwaltungsinformation)
- Anzahl Krankheitstage (ohne Diagnose)
- Versichertennummer
- Beitragssatz
## Warum ist das wichtig?
Gesundheitsdaten unterliegen dem besonderen Schutz nach Art. 9 DSGVO. Fuer ihre Verarbeitung braucht man eine **ausdrueckliche Rechtsgrundlage** (z.B. § 26 Abs. 3 BDSG im Beschaeftigungsverhaeltnis). Verwaltungsdaten wie der Krankenkassenname fallen unter die normalen Regeln.
## Praxis-Tipp
Wenn Sie im VVT oder in der DSFA Datenkategorien zuordnen: Pruefen Sie genau, ob ein Datum tatsaechlich Rueckschluesse auf den Gesundheitszustand zulaesst. Nur dann ist es ein Art.-9-Datum.',
ARRAY['Art. 9 DSGVO', '§ 26 Abs. 3 BDSG', 'ErwGr. 35 DSGVO'],
ARRAY['gesundheit', 'art9', 'abgrenzung', 'krankenkasse'],
'critical',
ARRAY['https://www.bfdi.bund.de', 'EuGH C-184/20'])
ON CONFLICT (id) DO NOTHING;
-- 2. Beschaeftigtendaten — Umfang
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('beschaeftigtendaten-umfang', 'datenkategorien',
'Beschaeftigtendaten — Was gehoert alles dazu?',
'Beschaeftigtendaten umfassen weit mehr als Name und Adresse. Hier eine Uebersicht der typischen Datenkategorien im Arbeitsverhaeltnis.',
'## Ueberblick
Im Arbeitsverhaeltnis fallen viele verschiedene personenbezogene Daten an. Sie alle unterliegen dem Beschaeftigtendatenschutz nach § 26 BDSG.
## Typische Beschaeftigtendaten
### Stammdaten
- Name, Adresse, Geburtsdatum
- Steuer-ID, Sozialversicherungsnummer
- Bankverbindung (fuer Gehaltsauszahlung)
### Vertragsdaten
- Arbeitsvertrag, Stellenbeschreibung
- Gehalt, Zulagen, Bonusvereinbarungen
- Arbeitszeit, Urlaubsanspruch
### Verwaltungsdaten
- Krankenkassenname, Beitragssatz
- Steuerklasse, Kinderfreibetraege
- Kirchensteuermerkmal
### Leistungsdaten
- Beurteilungen, Zielvereinbarungen
- Fortbildungsnachweise, Zertifikate
- Abmahnungen, Zwischenzeugnisse
## Abgrenzung zu Art.-9-Daten
Das **Kirchensteuermerkmal** verraet die Religionszugehoerigkeit und ist damit ein Art.-9-Datum. Die Steuerklasse hingegen ist ein normales Verwaltungsdatum.
## Praxis-Tipp
Erfassen Sie im VVT die Beschaeftigtendaten moeglichst nach Kategorien getrennt (Stammdaten, Vertragsdaten etc.). Das erleichtert spaeter die Zuordnung von Loeschfristen und Zugriffsrechten.',
ARRAY['§ 26 BDSG', 'Art. 6 Abs. 1b DSGVO', 'Art. 88 DSGVO'],
ARRAY['beschaeftigte', 'personal', 'stammdaten', 'lohnabrechnung'],
'important',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;
-- 3. Arbeitszeiterfassung — Pflicht
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('arbeitszeiterfassung-pflicht', 'arbeitsrecht',
'Arbeitszeiterfassung — Wer muss was erfassen?',
'Seit dem BAG-Beschluss 2022 besteht in Deutschland eine Pflicht zur systematischen Arbeitszeiterfassung. Das betrifft fast alle Unternehmen.',
'## Ueberblick
Das Bundesarbeitsgericht hat im September 2022 entschieden, dass Arbeitgeber die Arbeitszeiten ihrer Mitarbeiter systematisch erfassen muessen. Diese Pflicht ergibt sich aus dem Arbeitsschutzgesetz.
## Was muss erfasst werden?
- **Beginn** und **Ende** der taeglichen Arbeitszeit
- **Dauer** der Arbeitszeit
- **Ueberstunden** und Mehrarbeit
- Einhaltung der **Ruhezeiten** (mind. 11 Stunden)
- Einhaltung der **Pausenregelungen**
## Wer ist betroffen?
Grundsaetzlich alle Arbeitgeber — unabhaengig von der Unternehmensgroesse. Ausnahmen gibt es nur in sehr engen Grenzen (z.B. leitende Angestellte nach § 18 ArbZG).
## Datenschutz-Aspekte
Die Arbeitszeitdaten sind **personenbezogene Daten**. Die Rechtsgrundlage fuer die Erfassung ist die **rechtliche Verpflichtung** (Art. 6 Abs. 1c DSGVO i.V.m. § 3 ArbZG).
Wichtig: Die Daten duerfen **nicht** fuer andere Zwecke verwendet werden (z.B. Leistungskontrolle), es sei denn, es gibt dafuer eine eigene Rechtsgrundlage.
## Aufbewahrungsfrist
Arbeitszeitaufzeichnungen muessen mindestens **2 Jahre** aufbewahrt werden (§ 16 Abs. 2 ArbZG).
## Praxis-Tipp
Setzen Sie im VVT eine eigene Verarbeitungstaetigkeit "Arbeitszeiterfassung" auf und ordnen Sie die passende Rechtsgrundlage (Art. 6 Abs. 1c) zu.',
ARRAY['§ 3 ArbZG', '§ 16 Abs. 2 ArbZG', 'Art. 6 Abs. 1c DSGVO', 'BAG 1 ABR 22/21'],
ARRAY['arbeitszeit', 'zeiterfassung', 'bag', 'pflicht'],
'critical',
ARRAY['BAG 1 ABR 22/21 (13.09.2022)', 'EuGH C-55/18 (CCOO)'])
ON CONFLICT (id) DO NOTHING;
-- 4. HinSchG — Grundlagen
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('hinschg-grundlagen', 'hinschg',
'Hinweisgeberschutzgesetz — Ab wann gilt was?',
'Seit Dezember 2023 muessen alle Unternehmen ab 50 Mitarbeitern eine interne Meldestelle einrichten. Das hat auch datenschutzrechtliche Auswirkungen.',
'## Ueberblick
Das Hinweisgeberschutzgesetz (HinSchG) schuetzt Personen, die auf Missstaende in Unternehmen hinweisen ("Whistleblower"). Seit dem 17. Dezember 2023 gilt die volle Pflicht fuer Unternehmen ab 50 Beschaeftigten.
## Kernpflichten
### Interne Meldestelle einrichten
- Kann eine **interne Person** oder ein **externer Dienstleister** sein
- Meldungen muessen **muendlich, schriftlich und persoenlich** moeglich sein
- Eingangsbestaetigung innerhalb von **7 Tagen**
- Rueckmeldung an den Hinweisgeber innerhalb von **3 Monaten**
### Vertraulichkeitsgebot (§ 8 HinSchG)
- Die **Identitaet des Hinweisgebers** darf nur den zustaendigen Personen bekannt sein
- Verstoss ist bussgeld­bewehrt (bis 50.000 EUR)
## Welche Daten fallen an?
- Identitaet des Hinweisgebers (besonders schuetzenswert!)
- Beschuldigte Personen
- Zeugen und weitere Beteiligte
- Inhalt der Meldung (kann sensible Daten enthalten)
- Kommunikationsverlauf
## Datenschutz-Anforderungen
- **Eigene Verarbeitungstaetigkeit** im VVT anlegen
- Rechtsgrundlage: Art. 6 Abs. 1c DSGVO (rechtliche Verpflichtung)
- **Zugriffsbeschraenkung:** Nur die benannte Meldestelle darf auf die Daten zugreifen
- **Loeschfrist:** 3 Jahre nach Abschluss des Verfahrens (§ 11 Abs. 5 HinSchG)
- Bei Art.-9-Daten in Meldungen: besondere Schutzmassnahmen erforderlich
## Praxis-Tipp
Pruefen Sie bei externen Meldestellen-Anbietern, ob ein **AVV** erforderlich ist. In den meisten Faellen ja — der Anbieter verarbeitet personenbezogene Daten in Ihrem Auftrag.',
ARRAY['§ 8 HinSchG', '§ 11 Abs. 5 HinSchG', '§ 12 HinSchG', 'Art. 6 Abs. 1c DSGVO'],
ARRAY['hinweisgeberschutz', 'whistleblower', 'meldestelle', 'vertraulichkeit'],
'critical',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;
-- 5. AVV — Website-Betrieb
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('avv-website-betrieb', 'avv-dienstleister',
'AVV beim Website-Betrieb — Wer braucht einen Vertrag?',
'Beim Betrieb einer Website sind fast immer externe Dienstleister beteiligt. Fuer die meisten davon brauchen Sie einen Auftragsverarbeitungsvertrag.',
'## Ueberblick
Sobald ein externer Dienstleister in Ihrem Auftrag personenbezogene Daten verarbeitet, brauchen Sie einen **Auftragsverarbeitungsvertrag (AVV)** nach Art. 28 DSGVO.
## Typische AVV-Pflichten beim Website-Betrieb
| Dienstleister | AVV noetig? | Grund |
|--------------|-------------|-------|
| Hosting-Anbieter | Ja | Zugriff auf Server-Logs mit IP-Adressen |
| Newsletter-Tool | Ja | Verarbeitet E-Mail-Adressen |
| Analytics (Matomo gehostet) | Ja | Verarbeitet Nutzungsdaten |
| Cookie-Consent-Tool | Kommt drauf an | Nur wenn Daten beim Anbieter liegen |
| CDN (Cloudflare etc.) | Ja | IP-Adressen werden verarbeitet |
| Externer IT-Support | Ja | Potentieller Zugriff auf alle Daten |
## Was muss im AVV stehen?
- **Gegenstand und Dauer** der Verarbeitung
- **Art und Zweck** der Verarbeitung
- **Art der personenbezogenen Daten** (IP-Adressen, E-Mails etc.)
- **Kategorien betroffener Personen** (Website-Besucher, Newsletter-Abonnenten)
- **Technisch-organisatorische Massnahmen (TOMs)** des Dienstleisters
- **Unterauftragsverarbeiter** — muessen genehmigt werden
## Cookies & Analytics
Auch wenn Sie einen AVV mit dem Analytics-Anbieter haben: Die **datenschutzrechtliche Verantwortung** bleibt bei Ihnen! Sie muessen sicherstellen, dass eine gueltige Einwilligung vorliegt (§ 25 TDDDG).
## Praxis-Tipp
Fuehren Sie eine **Liste aller Dienstleister** mit Website-Bezug und pruefen Sie fuer jeden, ob ein AVV vorliegt. Viele Anbieter bieten Standard-AVVs zum Download an.',
ARRAY['Art. 28 DSGVO', '§ 25 TDDDG', 'Art. 32 DSGVO'],
ARRAY['avv', 'website', 'hosting', 'analytics', 'dienstleister'],
'important',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;
-- 6. AVV — Lohnbuchhaltung
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('avv-lohnbuchhaltung', 'avv-dienstleister',
'AVV bei externer Lohnbuchhaltung',
'Wer die Lohnabrechnung an einen externen Dienstleister auslagert, braucht zwingend einen Auftragsverarbeitungsvertrag — denn es werden sensible Beschaeftigtendaten uebermittelt.',
'## Ueberblick
Die externe Lohnbuchhaltung ist einer der haeufigsten Faelle von Auftragsverarbeitung. Der Dienstleister erhaelt umfangreiche personenbezogene Daten Ihrer Beschaeftigten.
## Welche Daten werden uebermittelt?
- Name, Adresse, Geburtsdatum
- Sozialversicherungsnummer
- Steuer-ID, Steuerklasse
- Krankenkasse, Beitragssaetze
- Gehalt, Zulagen, Praemien
- Arbeitszeiten, Fehlzeiten
- Ggf. Kirchensteuermerkmal (Art.-9-Datum!)
- Ggf. Pfaendungsdaten
## AVV-Pflicht
Ein AVV nach Art. 28 DSGVO ist **zwingend erforderlich**. Der Dienstleister handelt weisungsgebunden in Ihrem Auftrag.
## Besondere Schutzmassnahmen
Da potenziell Art.-9-Daten betroffen sind (Kirchensteuermerkmal → Religion), sollten folgende TOMs beim Dienstleister nachgewiesen werden:
- **Verschluesselung** der Datenuebertragung
- **Zugriffsbeschraenkung** auf die Lohndaten
- **Protokollierung** aller Zugriffe
- **Regelmaessige Audits** des Dienstleisters
## Praxis-Tipp
Pruefen Sie, ob der Lohnbuchhaltungs-Dienstleister seinerseits **Unterauftragsverarbeiter** einsetzt (z.B. Cloud-Hosting, DATEV). Diese muessen im AVV aufgefuehrt sein.',
ARRAY['Art. 28 DSGVO', '§ 26 BDSG', 'Art. 9 DSGVO', 'Art. 32 DSGVO'],
ARRAY['avv', 'lohnbuchhaltung', 'personal', 'beschaeftigte'],
'important',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;
-- 7. Religion bei Bewerbungen
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('religion-bewerbung', 'art9-besondere',
'Religion im Bewerbungsverfahren — Was darf gefragt werden?',
'Die Religionszugehoerigkeit ist ein besonders geschuetztes Datum nach Art. 9 DSGVO. Im Bewerbungsverfahren gelten strenge Regeln.',
'## Ueberblick
Die Religionszugehoerigkeit faellt unter die **besonderen Kategorien** personenbezogener Daten (Art. 9 DSGVO). Im Bewerbungsverfahren darf grundsaetzlich **nicht** danach gefragt werden.
## Frageverbote
Das **Allgemeine Gleichbehandlungsgesetz (AGG)** verbietet die Benachteiligung wegen der Religion. Daraus folgt:
- **Keine Frage** nach der Religionszugehoerigkeit im Bewerbungsgespraech
- **Kein Feld** "Religion" im Bewerbungsformular
- **Keine Rueckschluesse** aus dem Lebenslauf ziehen (z.B. Mitgliedschaft in religioesen Organisationen)
## Ausnahmen
Eine Ausnahme gilt fuer **Tendenzbetriebe** (z.B. kirchliche Einrichtungen). Hier kann die Religionszugehoerigkeit eine wesentliche und gerechtfertigte berufliche Anforderung sein — allerdings mit Einschraenkungen nach der EuGH-Rechtsprechung.
## Wann Religion doch relevant wird
Spaetestens bei der **Lohnabrechnung** wird die Religionszugehoerigkeit relevant, weil das Kirchensteuermerkmal uebermittelt werden muss. Dies ist dann durch **§ 26 Abs. 3 BDSG** gedeckt.
## Praxis-Tipp
Gestalten Sie Bewerbungsformulare so, dass keine besonderen Kategorien abgefragt werden. Fuehren Sie im VVT die Verarbeitung "Bewerbermanagement" mit den richtigen Datenkategorien — und listen Sie Religion dort **nicht** auf.',
ARRAY['Art. 9 DSGVO', '§ 1 AGG', '§ 26 Abs. 3 BDSG', 'Art. 4 Nr. 13 DSGVO'],
ARRAY['religion', 'bewerbung', 'art9', 'agg', 'diskriminierung'],
'important',
ARRAY['EuGH C-414/16 (Egenberger)'])
ON CONFLICT (id) DO NOTHING;
-- 8. Kontaktdaten von Ansprechpartnern
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('kontaktdaten-ansprechpartner', 'datenkategorien',
'Kontaktdaten von Kunden- und Lieferanten-Ansprechpartnern',
'Auch die Kontaktdaten von Ansprechpartnern bei Geschaeftspartnern sind personenbezogene Daten und muessen datenschutzkonform verarbeitet werden.',
'## Ueberblick
In jedem CRM-System, jeder SAP-Kontaktpflege und jedem E-Mail-Verteiler werden personenbezogene Daten von **Ansprechpartnern** bei Kunden und Lieferanten gespeichert. Diese Daten unterliegen der DSGVO.
## Typische Daten
- Name, Vorname, Titel
- Geschaeftliche E-Mail-Adresse
- Geschaeftliche Telefonnummer
- Position / Abteilung
- Ggf. Foto (z.B. in Kontaktdatenbanken)
## Rechtsgrundlage
Die uebliche Rechtsgrundlage ist das **berechtigte Interesse** (Art. 6 Abs. 1f DSGVO). Die Geschaeftsbeziehung macht es erforderlich, Ansprechpartner zu kennen und zu kontaktieren.
## Informationspflicht
Auch Ansprechpartner bei Geschaeftspartnern muessen ueber die Datenverarbeitung informiert werden (Art. 13/14 DSGVO). In der Praxis geschieht das oft ueber:
- Einen Datenschutzhinweis in der E-Mail-Signatur
- Einen Link zur Datenschutzerklaerung in der Auftragsbestaetigung
- Einen separaten Datenschutzhinweis bei Vertragsabschluss
## Praxis-Tipp
Fuehren Sie im VVT eine Verarbeitungstaetigkeit "Kunden-/Lieferantenmanagement" mit der Datenkategorie "Geschaeftliche Kontaktdaten" auf. Die Rechtsgrundlage ist in der Regel Art. 6 Abs. 1f DSGVO.',
ARRAY['Art. 6 Abs. 1f DSGVO', 'Art. 13 DSGVO', 'Art. 14 DSGVO'],
ARRAY['kontaktdaten', 'crm', 'kunden', 'lieferanten', 'b2b'],
'info',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;
-- 9. Gemeinsame Verantwortlichkeit
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('gemeinsame-verantwortlichkeit', 'dsgvo-grundlagen',
'Gemeinsame Verantwortlichkeit vs. Auftragsverarbeitung',
'Die Abgrenzung zwischen Art. 26 (gemeinsame Verantwortlichkeit) und Art. 28 (Auftragsverarbeitung) ist in der Praxis oft schwierig, aber entscheidend fuer die richtige vertragliche Gestaltung.',
'## Ueberblick
Wenn zwei oder mehr Stellen gemeinsam ueber **Zwecke und Mittel** der Datenverarbeitung entscheiden, liegt eine **gemeinsame Verantwortlichkeit** nach Art. 26 DSGVO vor. Das ist etwas anderes als eine Auftragsverarbeitung (Art. 28), bei der ein Dienstleister weisungsgebunden handelt.
## Auftragsverarbeitung (Art. 28)
Der Auftragsverarbeiter:
- Handelt **weisungsgebunden**
- Entscheidet **nicht** ueber Zweck und Mittel
- Verarbeitet Daten **nur im Auftrag** des Verantwortlichen
**Beispiele:** Hosting-Anbieter, externe Lohnbuchhaltung, Cloud-Speicher
## Gemeinsame Verantwortlichkeit (Art. 26)
Beide Parteien:
- Entscheiden **gemeinsam** ueber Zwecke und/oder Mittel
- Haben **eigene Interessen** an der Verarbeitung
- Muessen eine **Vereinbarung** ueber ihre jeweiligen Pflichten treffen
**Beispiele:**
- Facebook-Fanpage (EuGH Wirtschaftsakademie)
- Gemeinsame Kundendatenbank zweier Unternehmen
- Konzernweites HR-System mit gemeinsamer Steuerung
## Wann wird aus AVV eine gemeinsame Verantwortlichkeit?
Sobald der "Auftragsverarbeiter" beginnt, Daten fuer **eigene Zwecke** zu nutzen (z.B. eigene Analysen, Produktverbesserung mit Kundendaten), verschiebt sich die Rolle Richtung gemeinsame Verantwortlichkeit.
## Praxis-Tipp
Pruefen Sie bei jedem Dienstleister: Hat er ein **eigenes Interesse** an den Daten? Nutzt er sie fuer **eigene Zwecke**? Wenn ja, brauchen Sie eine Art.-26-Vereinbarung statt eines AVV.',
ARRAY['Art. 26 DSGVO', 'Art. 28 DSGVO', 'Art. 4 Nr. 7 DSGVO'],
ARRAY['art26', 'art28', 'avv', 'verantwortlichkeit', 'joint-controller'],
'important',
ARRAY['EuGH C-210/16 (Wirtschaftsakademie)', 'EuGH C-40/17 (Fashion ID)'])
ON CONFLICT (id) DO NOTHING;
-- 10. Qualifikationsdaten
INSERT INTO compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls) VALUES
('qualifikationsdaten', 'datenkategorien',
'Qualifikationsdaten — Fortbildungen, Zertifikate, Schulungsnachweise',
'Qualifikationsdaten gehoeren zu den Beschaeftigtendaten und unterliegen besonderen Aufbewahrungsregeln.',
'## Ueberblick
Qualifikationsdaten dokumentieren die beruflichen Faehigkeiten und Weiterbildungen von Beschaeftigten. Sie sind personenbezogene Daten und gehoeren zu den Beschaeftigtendaten.
## Was sind Qualifikationsdaten?
- Abschlusszeugnisse und Studiennachweise
- Berufliche Zertifizierungen (z.B. ISO-Auditor, Datenschutzbeauftragter)
- Teilnahmenachweise fuer Fortbildungen
- Schulungsnachweise (z.B. Arbeitssicherheit, Datenschutz)
- Fuehrerscheine / Fahrerlaubnisse (bei Relevanz fuer den Job)
- Sprachkenntnisse, IT-Kenntnisse
## Rechtsgrundlage
- **Waehrend des Arbeitsverhaeltnisses:** § 26 BDSG (Durchfuehrung des Beschaeftigungsverhaeltnisses)
- **Bei Pflichtschulungen:** Art. 6 Abs. 1c DSGVO (z.B. Arbeitssicherheitsunterweisungen)
## Aufbewahrungsfristen
| Datum | Frist | Grund |
|-------|-------|-------|
| Unterweisungsnachweise (Arbeitssicherheit) | Dauer des Arbeitsverhaeltnisses | ArbSchG |
| Fortbildungsnachweise | 3 Jahre nach Ende des AV | Nachweis der Personalentwicklung |
| Pflichtschulungen (z.B. Datenschutz) | 3 Jahre nach Durchfuehrung | Nachweispflicht |
| Fuehrerscheinkopien | Regelmaessige Ueberpruefung | UVV |
## Praxis-Tipp
Fuehren Sie Qualifikationsdaten als eigene Datenkategorie im VVT. Achten Sie auf die **Zweckbindung**: Schulungsnachweise zum Datenschutz duerfen nicht fuer die Leistungsbewertung herangezogen werden.',
ARRAY['§ 26 BDSG', 'Art. 6 Abs. 1c DSGVO', 'Art. 17 DSGVO'],
ARRAY['qualifikation', 'fortbildung', 'schulung', 'zertifikate', 'personal'],
'info',
ARRAY[]::text[])
ON CONFLICT (id) DO NOTHING;

File diff suppressed because it is too large Load Diff