shieldx/ai-act-kompass/src/ui/SettingsView.tsx
Claude cd31274738
feat(ai-act-kompass): i18n, national modules, task checklist, legal gate, new documents
- Full i18n (DE/EN): UI, rules engine, content pack and generated
  documents; language switcher, browser-language default, architecture
  extensible to further EU languages via localized source texts
- National country modules (opt-in per company profile): DE (KI-MIG/
  BNetzA supervision, works council co-determination Sec. 87 BetrVG,
  GDPR/BDSG), AT (RTR AI service desk), IT (Law 132/2025: employer info
  duty, deepfake criminal liability, sector decrees), ES (AESIA,
  sandbox RD 817/2023), FR (CNIL guidance), NL (algorithm register);
  merged into obligation derivation, checklists and documents
- New EU obligations: corrective actions (Art. 20/21), authorised
  representative (Art. 22), EU database registration for public-body
  deployers (Art. 49(3)), post-market monitoring (Art. 72), serious
  incident reporting (Art. 73), GDPR DPIA (Art. 35 GDPR / 26(9))
- New documents: AI procurement requirements specification (MUST/SHOULD
  matrix for vendors) and EU declaration of conformity draft (Annex V)
- Cross-system checkable task list (due now / upcoming / done) with
  progress bar, one-tap toggling, per-system links
- Legal safety: first-run acknowledgement gate (working aid, no legal
  services), legal notice panel in settings, result-screen disclaimer,
  disclaimers on every document
- Responsive layout for phone/tablet (top-bar nav, scrollable tables,
  larger tap targets)
- 12 new tests (44 total): EN pack parity, national merge, checklist
  grouping, new document generators

Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
2026-07-04 09:18:07 +00:00

246 lines
8.0 KiB
TypeScript

import { useRef, useState } from 'react';
import type { AISystem, CompanyProfile } from '../engine/types';
import { useI18n } from '../i18n/context';
import { LOCALES } from '../i18n/types';
import { fmt } from '../i18n/ui';
import { clearAll, exportData, importData } from '../store/storage';
import { downloadText, todayISO } from './common';
interface SettingsViewProps {
readonly systems: readonly AISystem[];
readonly profile: CompanyProfile;
readonly onProfileChange: (profile: CompanyProfile) => void;
readonly onImport: (systems: readonly AISystem[], profile: CompanyProfile | null) => void;
readonly onClear: () => void;
}
const MAX_LOGO_BYTES = 300_000;
type ProfileTextKey = Exclude<keyof CompanyProfile, 'logoDataUrl' | 'countries'>;
/** Einstellungen: Sprache, Firmenprofil, Länder-Module, Backup, Rechtshinweis. */
export function SettingsView({
systems,
profile,
onProfileChange,
onImport,
onClear,
}: SettingsViewProps): JSX.Element {
const { t, locale, pack, setLocale } = useI18n();
const fileInput = useRef<HTMLInputElement>(null);
const logoInput = useRef<HTMLInputElement>(null);
const [message, setMessage] = useState<string>('');
const [logoMessage, setLogoMessage] = useState<string>('');
const profileFields: readonly { key: ProfileTextKey; label: string; placeholder: string }[] = [
{ key: 'name', label: t.pfName, placeholder: 'Muster GmbH' },
{ key: 'street', label: t.pfStreet, placeholder: 'Musterstraße 1' },
{ key: 'zipCity', label: t.pfZip, placeholder: '60311 Frankfurt am Main' },
{ key: 'contactPerson', label: t.pfContact, placeholder: 'Max Mustermann' },
{ key: 'email', label: t.pfEmail, placeholder: 'compliance@muster.de' },
{ key: 'phone', label: t.pfPhone, placeholder: '+49 69 000000' },
{ key: 'website', label: t.pfWeb, placeholder: 'www.muster.de' },
];
const handleImport = async (file: File): Promise<void> => {
try {
const result = importData(await file.text());
onImport(result.systems, result.profile);
setMessage(
fmt(t.importOk, {
count: result.systems.length,
profile: result.profile ? t.importProfileSuffix : '',
}),
);
} catch (err) {
setMessage(fmt(t.importFail, { error: err instanceof Error ? err.message : String(err) }));
}
};
const handleLogo = (file: File): void => {
if (!file.type.startsWith('image/')) {
setLogoMessage(t.logoNotImage);
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === 'string' ? reader.result : '';
if (dataUrl.length > MAX_LOGO_BYTES * 1.4) {
setLogoMessage(t.logoTooBig);
return;
}
onProfileChange({ ...profile, logoDataUrl: dataUrl });
setLogoMessage(t.logoTaken);
};
reader.readAsDataURL(file);
};
const toggleCountry = (code: string): void => {
const countries = profile.countries.includes(code)
? profile.countries.filter((c) => c !== code)
: [...profile.countries, code];
onProfileChange({ ...profile, countries });
};
return (
<div>
<h1>{t.settingsTitle}</h1>
<p className="sub">{t.settingsSub}</p>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.langTitle}</h2>
<div className="toolbar">
{LOCALES.map((l) => (
<button
key={l}
className={l === locale ? 'primary' : 'ghost'}
onClick={() => setLocale(l)}
>
{l === 'de' ? 'Deutsch' : 'English'}
</button>
))}
</div>
<p className="hint">{t.langHint}</p>
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.countriesTitle}</h2>
<p className="hint">{t.countriesHint}</p>
{pack.national.map((n) => (
<label key={n.code} className="check">
<input
type="checkbox"
checked={profile.countries.includes(n.code)}
onChange={() => toggleCountry(n.code)}
/>
<span>
<strong>
{n.code} {n.country}
</strong>
<span className="ref">
{t.authorityLabel}: {n.authority}
</span>
</span>
</label>
))}
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.profileTitle}</h2>
<p className="hint">{t.profileHint}</p>
<div className="profile-grid">
{profileFields.map((f) => (
<label key={f.key} className="field">
<span>{f.label}</span>
<input
type="text"
value={profile[f.key]}
placeholder={f.placeholder}
onChange={(e) => onProfileChange({ ...profile, [f.key]: e.target.value })}
/>
</label>
))}
</div>
<div className="logo-row">
{profile.logoDataUrl ? (
<img className="logo-preview" src={profile.logoDataUrl} alt="Logo" />
) : (
<span className="hint">{t.logoNone}</span>
)}
<div className="toolbar" style={{ margin: 0 }}>
<button className="ghost" onClick={() => logoInput.current?.click()}>
{t.logoUpload}
</button>
{profile.logoDataUrl && (
<button className="danger" onClick={() => onProfileChange({ ...profile, logoDataUrl: '' })}>
{t.logoRemove}
</button>
)}
</div>
<input
ref={logoInput}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleLogo(file);
e.target.value = '';
}}
/>
</div>
{logoMessage && <p className="hint">{logoMessage}</p>}
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.packTitle}</h2>
<p style={{ fontSize: 14 }}>
Version <strong>{pack.version}</strong> · {t.packStand} {pack.releasedAt}
<br />
<span className="hint">{pack.legalBasis}</span>
</p>
<p className="hint">{t.packHint}</p>
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.backupTitle}</h2>
<div className="toolbar">
<button
className="primary"
onClick={() =>
downloadText(
`ai-act-kompass-backup-${todayISO()}.json`,
exportData(systems, profile, new Date().toISOString()),
'application/json',
)
}
>
{t.backupExport}
</button>
<button className="ghost" onClick={() => fileInput.current?.click()}>
{t.backupImport}
</button>
<input
ref={fileInput}
type="file"
accept="application/json"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleImport(file);
e.target.value = '';
}}
/>
</div>
{message && <p className="hint">{message}</p>}
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.legalNoticeTitle}</h2>
<p className="hint">{t.legalGateBody1}</p>
<p className="hint">
<strong>{t.legalGateBody2}</strong>
</p>
<p className="hint">{t.legalGateBody3}</p>
</div>
<div className="panel">
<h2 style={{ marginTop: 0 }}>{t.deleteTitle}</h2>
<p className="hint">{t.deleteHint}</p>
<button
className="danger"
onClick={() => {
if (window.confirm(t.deleteConfirm)) {
clearAll();
onClear();
setMessage(t.deleteDone);
}
}}
>
{t.deleteBtn}
</button>
</div>
</div>
);
}