- 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
246 lines
8.0 KiB
TypeScript
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>
|
|
);
|
|
}
|