feat(ai-act-kompass): AI literacy test (Art. 4) with certificate and training register

- Simple 12-question basics quiz ('for dummies'): how AI works,
  hallucinations, confidential data, human oversight, chatbot
  disclosure (Art. 50), prohibited practices (Art. 5), high-risk
  (Annex III), GDPR, internal incident reporting (Art. 73), shadow AI
  approval, content marking, the Art. 4 duty itself — 3 options each,
  pass threshold 70%
- Evaluation view with per-question explanations (learning effect),
  pass/fail stamp badge
- Printable certificate (letterhead) as Art. 4 evidence: name, date,
  score, topics covered, signature lines — files into the existing
  training plan documentation
- Local training register (localStorage): every run recorded (name,
  date, score, status), printable as continuous Art. 4 evidence
  register; cleared with all-data delete
- New nav entry; UI strings in all nine languages; question bank
  currently DE/EN (translations for the other seven languages follow
  in the next commit)
- 6 new tests (69 total)

Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
This commit is contained in:
Claude 2026-07-04 11:09:55 +00:00
parent 3a08392669
commit 21946a157f
No known key found for this signature in database
14 changed files with 933 additions and 2 deletions

View File

@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { CONTENT_PACK } from '../content/pack';
import { certificateDoc, getQuiz, scoreQuiz, trainingLogDoc } from './quiz';
import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from './quizContent';
import type { CompanyProfile } from './types';
const profile: CompanyProfile = {
name: 'Muster GmbH',
street: '',
zipCity: '',
contactPerson: '',
email: '',
phone: '',
website: '',
logoDataUrl: '',
countries: [],
};
describe('KI-Kompetenztest', () => {
it('hat 12 einfache Fragen mit je 3 Optionen und gültigem Lösungsindex', () => {
expect(QUIZ_QUESTIONS).toHaveLength(12);
for (const q of QUIZ_QUESTIONS) {
expect(q.options).toHaveLength(3);
expect([0, 1, 2]).toContain(q.correctIndex);
}
});
it('löst Fragen lokalisiert auf (DE und EN unterscheiden sich)', () => {
const de = getQuiz('de');
const en = getQuiz('en');
expect(de).toHaveLength(en.length);
expect(de[0]?.question).not.toBe(en[0]?.question);
});
it('wertet korrekt aus: alle richtig = bestanden, alle falsch = durchgefallen', () => {
const perfect = scoreQuiz(QUIZ_QUESTIONS.map((q) => q.correctIndex));
expect(perfect.pct).toBe(100);
expect(perfect.passed).toBe(true);
const wrong = scoreQuiz(QUIZ_QUESTIONS.map((q) => ((q.correctIndex + 1) % 3) as 0 | 1 | 2));
expect(wrong.correct).toBe(0);
expect(wrong.passed).toBe(false);
});
it('wendet die Bestehensgrenze an (knapp darunter = durchgefallen)', () => {
// 8 von 12 = 67 % < 70 % → nicht bestanden; 9 von 12 = 75 % → bestanden
const eight = scoreQuiz(
QUIZ_QUESTIONS.map((q, i) => (i < 8 ? q.correctIndex : ((q.correctIndex + 1) % 3) as 0 | 1 | 2)),
);
expect(eight.passed).toBe(false);
const nine = scoreQuiz(
QUIZ_QUESTIONS.map((q, i) => (i < 9 ? q.correctIndex : ((q.correctIndex + 1) % 3) as 0 | 1 | 2)),
);
expect(nine.passed).toBe(true);
expect(QUIZ_PASS_PCT).toBe(70);
});
it('erzeugt ein Zertifikat mit Name, Ergebnis, Art.-4-Bezug und Disclaimer', () => {
const score = scoreQuiz(QUIZ_QUESTIONS.map((q) => q.correctIndex));
const md = certificateDoc('Max Mustermann', score, CONTENT_PACK, '2026-07-04', profile);
expect(md).toContain('Nachweis KI-Kompetenz (Art. 4');
expect(md).toContain('Max Mustermann');
expect(md).toContain('BESTANDEN');
expect(md).toContain('Muster GmbH');
expect(md).toContain('keine Rechtsberatung');
});
it('erzeugt das Schulungsregister mit allen Durchläufen', () => {
const md = trainingLogDoc(
[
{ id: '1', name: 'Max Mustermann', date: '2026-07-04', scorePct: 92, passed: true },
{ id: '2', name: 'Erika Musterfrau', date: '2026-07-03', scorePct: 58, passed: false },
],
CONTENT_PACK,
'2026-07-04',
profile,
);
expect(md).toContain('Schulungsregister');
expect(md).toContain('Max Mustermann');
expect(md).toContain('NICHT BESTANDEN');
expect(md).toContain('70 %');
});
});

View File

@ -0,0 +1,202 @@
import { resolveText } from '../i18n/types';
import type { Locale } from '../i18n/types';
import { docStrings, formatDocDate } from './docStrings';
import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from './quizContent';
import type { CompanyProfile, ContentPack } from './types';
/** Aufgelöste Quizfrage in einer Zielsprache. */
export interface QuizQuestion {
readonly id: string;
readonly question: string;
readonly options: readonly [string, string, string];
readonly correctIndex: 0 | 1 | 2;
readonly explanation: string;
}
/** Testergebnis einer Person. */
export interface QuizScore {
readonly correct: number;
readonly total: number;
readonly pct: number;
readonly passed: boolean;
}
/** Eintrag im lokalen Schulungsregister (Art.-4-Nachweis). */
export interface TrainingRecord {
readonly id: string;
readonly name: string;
readonly date: string;
readonly scorePct: number;
readonly passed: boolean;
}
/** Liefert den Fragenkatalog in der gewünschten Sprache. */
export function getQuiz(locale: Locale): readonly QuizQuestion[] {
return QUIZ_QUESTIONS.map((q) => ({
id: q.id,
question: resolveText(q.question, locale),
options: [
resolveText(q.options[0], locale),
resolveText(q.options[1], locale),
resolveText(q.options[2], locale),
],
correctIndex: q.correctIndex,
explanation: resolveText(q.explanation, locale),
}));
}
/**
* Wertet abgegebene Antworten aus.
*
* @param answers - Gewählter Optionsindex je Frage (Reihenfolge des Katalogs)
*/
export function scoreQuiz(answers: readonly (number | null)[]): QuizScore {
const total = QUIZ_QUESTIONS.length;
const correct = QUIZ_QUESTIONS.reduce(
(acc, q, i) => acc + (answers[i] === q.correctIndex ? 1 : 0),
0,
);
const pct = Math.round((correct / total) * 100);
return { correct, total, pct, passed: pct >= QUIZ_PASS_PCT };
}
interface CertStrings {
readonly title: string;
readonly intro: string;
readonly name: string;
readonly date: string;
readonly result: string;
readonly resultPassed: string;
readonly resultFailed: string;
readonly topics: string;
readonly topicList: string;
readonly evidence: string;
readonly signature: string;
readonly logTitle: string;
readonly logIntro: string;
readonly logCols: string;
}
const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
de: {
title: 'Nachweis KI-Kompetenz (Art. 4 EU-KI-VO)',
intro:
'Die nachfolgend genannte Person hat den KI-Kompetenztest des Unternehmens absolviert. Der Test vermittelt und prüft die Grundlagen für den verantwortungsvollen Umgang mit KI-Systemen.',
name: 'Name',
date: 'Datum',
result: 'Ergebnis',
resultPassed: 'BESTANDEN',
resultFailed: 'NICHT BESTANDEN — Wiederholung erforderlich',
topics: 'Geprüfte Themen',
topicList:
'Funktionsweise und Grenzen von KI · Umgang mit vertraulichen Daten · Prüfung von KI-Ausgaben (Halluzinationen) · menschliche Aufsicht · Transparenzpflichten (Art. 50) · verbotene Praktiken (Art. 5) · Hochrisiko-KI (Annex III) · DSGVO · interne Meldewege (Art. 73) · Freigabeprozess (Schatten-KI) · Kennzeichnung KI-generierter Inhalte · KI-Kompetenzpflicht (Art. 4)',
evidence:
'Dieser Nachweis ist Teil der Art.-4-Dokumentation des Unternehmens (Schulungsplan & Nachweis KI-Kompetenz) und wird dort abgelegt.',
signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________',
logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)',
logIntro: 'Fortlaufender Nachweis der durchgeführten KI-Kompetenztests. Bestehensgrenze: {pct} %.',
logCols: '| Datum | Name | Ergebnis | Status |',
},
en: {
title: 'Evidence of AI literacy (Art. 4 EU AI Act)',
intro:
'The person named below has completed the companys AI literacy test. The test teaches and verifies the basics of responsible use of AI systems.',
name: 'Name',
date: 'Date',
result: 'Result',
resultPassed: 'PASSED',
resultFailed: 'NOT PASSED — retake required',
topics: 'Topics covered',
topicList:
'How AI works and its limits · handling confidential data · checking AI output (hallucinations) · human oversight · transparency obligations (Art. 50) · prohibited practices (Art. 5) · high-risk AI (Annex III) · GDPR · internal reporting channels (Art. 73) · approval process (shadow AI) · marking AI-generated content · AI literacy duty (Art. 4)',
evidence:
'This evidence forms part of the companys Art. 4 documentation (AI literacy training plan & record) and is filed there.',
signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________',
logTitle: 'AI literacy training register (Art. 4 EU AI Act)',
logIntro: 'Continuous record of completed AI literacy tests. Pass threshold: {pct} %.',
logCols: '| Date | Name | Score | Status |',
},
};
function certStrings(locale: Locale): CertStrings {
return locale === 'de' ? CERT.de : CERT.en;
}
/**
* Erzeugt das druckbare Zertifikat (Art.-4-Nachweis) als Markdown.
*
* @param name - Name der geprüften Person
* @param score - Testergebnis
* @param pack - Content-Pack (Versionsangabe)
* @param today - Datum im ISO-Format
* @param profile - Firmenprofil (Briefkopf)
* @param locale - Dokumentsprache
*/
export function certificateDoc(
name: string,
score: QuizScore,
pack: ContentPack,
today: string,
profile?: CompanyProfile,
locale: Locale = 'de',
): string {
const s = docStrings(locale);
const c = certStrings(locale);
const company = profile && profile.name.trim() !== '' ? profile.name : '_______________';
return [
`# ${c.title}`,
'',
`**${s.meta.company}:** ${company} · Content-Pack v${pack.version}`,
'',
s.disclaimer,
'',
c.intro,
'',
`| | |`,
`|---|---|`,
`| **${c.name}** | ${name} |`,
`| **${c.date}** | ${formatDocDate(today, locale)} |`,
`| **${c.result}** | ${score.correct}/${score.total} (${score.pct} %) — **${score.passed ? c.resultPassed : c.resultFailed}** |`,
'',
`## ${c.topics}`,
'',
c.topicList,
'',
c.evidence,
'',
c.signature,
].join('\n');
}
/**
* Erzeugt das druckbare Schulungsregister (alle Testdurchläufe) als Markdown.
*/
export function trainingLogDoc(
records: readonly TrainingRecord[],
pack: ContentPack,
today: string,
profile?: CompanyProfile,
locale: Locale = 'de',
): string {
const s = docStrings(locale);
const c = certStrings(locale);
const company = profile && profile.name.trim() !== '' ? profile.name : '_______________';
const lines = [
`# ${c.logTitle}`,
'',
`**${s.meta.company}:** ${company} · ${s.inventory.asOf}: ${formatDocDate(today, locale)} · Content-Pack v${pack.version}`,
'',
s.disclaimer,
'',
c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)),
'',
c.logCols,
'|---|---|---|---|',
];
for (const r of records) {
lines.push(
`| ${formatDocDate(r.date, locale)} | ${r.name} | ${r.scorePct} % | ${r.passed ? c.resultPassed : c.resultFailed} |`,
);
}
return lines.join('\n');
}

View File

@ -0,0 +1,233 @@
import { lt } from '../i18n/types';
import type { LText } from '../i18n/types';
/**
* Fragenkatalog des KI-Kompetenztests (Art. 4 EU-KI-VO).
*
* Bewusst einfach gehalten (für Dummies"): 12 Fragen zu den
* Grundlagen, die jede/r Beschäftigte kennen muss KI-Basics,
* Verbote, Transparenz, Datenschutz, interne Prozesse.
* Teil des versionierten Content-Packs; Übersetzungen folgen dem
* lt()-Schema (DE/EN Pflicht, weitere Sprachen ergänzbar).
*/
/** Frage in Quellform (lokalisiert). */
export interface QuizQuestionSrc {
readonly id: string;
readonly question: LText;
/** Genau drei Antwortoptionen. */
readonly options: readonly [LText, LText, LText];
/** Index der richtigen Option (02). */
readonly correctIndex: 0 | 1 | 2;
readonly explanation: LText;
}
/** Bestehensgrenze in Prozent. */
export const QUIZ_PASS_PCT = 70;
export const QUIZ_QUESTIONS: readonly QuizQuestionSrc[] = [
{
id: 'q-basics',
question: lt(
'Was macht ein KI-System — vereinfacht gesagt?',
'What does an AI system do — put simply?',
),
options: [
lt('Es folgt immer exakt fest einprogrammierten Regeln.', 'It always follows exactly pre-programmed rules.'),
lt('Es leitet aus Daten selbstständig ab, welche Ausgabe passt — und kann dabei auch danebenliegen.', 'It infers from data which output fits — and can also get it wrong.'),
lt('Es versteht Inhalte wie ein Mensch und macht deshalb keine Fehler.', 'It understands content like a human and therefore makes no mistakes.'),
],
correctIndex: 1,
explanation: lt(
'KI erzeugt Ausgaben statistisch aus Daten. Sie kann überzeugend klingen und trotzdem falsch liegen.',
'AI generates outputs statistically from data. It can sound convincing and still be wrong.',
),
},
{
id: 'q-secrets',
question: lt(
'Dürfen Sie Kundendaten oder Geschäftsgeheimnisse in einen öffentlichen KI-Chatbot eingeben?',
'May you enter customer data or trade secrets into a public AI chatbot?',
),
options: [
lt('Ja, wenn es die Arbeit beschleunigt.', 'Yes, if it speeds up the work.'),
lt('Ja, solange ich das Ergebnis danach lösche.', 'Yes, as long as I delete the result afterwards.'),
lt('Nein — nur in vom Unternehmen freigegebene KI-Systeme.', 'No — only into AI systems approved by the company.'),
],
correctIndex: 2,
explanation: lt(
'Eingaben in fremde Cloud-KI können gespeichert und weiterverwendet werden. Es gilt die interne KI-Richtlinie: nur freigegebene Systeme nutzen.',
'Input into third-party cloud AI can be stored and reused. The internal AI policy applies: use approved systems only.',
),
},
{
id: 'q-hallucination',
question: lt(
'Eine KI liefert Ihnen eine Antwort mit Quellenangaben. Was tun Sie?',
'An AI gives you an answer with source references. What do you do?',
),
options: [
lt('Wichtige Fakten und Quellen prüfen, bevor ich sie verwende — KI erfindet manchmal beides.', 'Check important facts and sources before using them — AI sometimes invents both.'),
lt('Direkt übernehmen — mit Quellen ist es verlässlich.', 'Use it directly — with sources it is reliable.'),
lt('Die KI bitten, zu bestätigen, dass alles stimmt.', 'Ask the AI to confirm everything is correct.'),
],
correctIndex: 0,
explanation: lt(
'„Halluzinationen" sind erfundene, aber plausibel klingende Angaben — auch Quellen können erfunden sein. Selbstbestätigung der KI ist kein Beleg.',
'“Hallucinations” are invented but plausible-sounding statements — sources can be invented too. AI self-confirmation is no proof.',
),
},
{
id: 'q-oversight',
question: lt(
'Die KI schlägt vor, einen Bewerber abzulehnen. Wer entscheidet?',
'The AI suggests rejecting an applicant. Who decides?',
),
options: [
lt('Die KI — dafür ist sie da.', 'The AI — that is what it is for.'),
lt('Ein Mensch prüft den Vorschlag und trifft die Entscheidung.', 'A human reviews the suggestion and takes the decision.'),
lt('Niemand — die Entscheidung fällt automatisch nach 48 Stunden.', 'No one — the decision is taken automatically after 48 hours.'),
],
correctIndex: 1,
explanation: lt(
'Bei Entscheidungen mit Wirkung für Menschen ist die KI nur Unterstützung — die Letztentscheidung trifft ein kompetenter Mensch (menschliche Aufsicht).',
'For decisions affecting people, AI is support only — the final decision is taken by a competent human (human oversight).',
),
},
{
id: 'q-transparency',
question: lt(
'Ihr Unternehmen setzt einen Kunden-Chatbot ein. Was ist Pflicht?',
'Your company uses a customer chatbot. What is mandatory?',
),
options: [
lt('Nichts — Hauptsache, er antwortet freundlich.', 'Nothing — as long as it answers politely.'),
lt('Kunden müssen erkennen können, dass sie mit einer KI sprechen.', 'Customers must be able to recognise they are talking to an AI.'),
lt('Der Chatbot muss sich als Mensch ausgeben, damit Kunden Vertrauen haben.', 'The chatbot must pose as a human so customers trust it.'),
],
correctIndex: 1,
explanation: lt(
'Art. 50 EU-KI-VO: KI-Interaktion muss offengelegt werden; KI-generierte Inhalte sind zu kennzeichnen.',
'Art. 50 EU AI Act: AI interaction must be disclosed; AI-generated content must be marked.',
),
},
{
id: 'q-prohibited',
question: lt(
'Welche dieser KI-Anwendungen ist in der EU verboten?',
'Which of these AI applications is prohibited in the EU?',
),
options: [
lt('Rechtschreibkorrektur in E-Mails.', 'Spell-checking in emails.'),
lt('Emotionserkennung zur Überwachung der Stimmung am Arbeitsplatz.', 'Emotion recognition to monitor mood at the workplace.'),
lt('Automatische Übersetzung von Dokumenten.', 'Automatic translation of documents.'),
],
correctIndex: 1,
explanation: lt(
'Art. 5 EU-KI-VO verbietet u. a. Emotionserkennung am Arbeitsplatz sowie Social Scoring und manipulative Techniken — seit Februar 2025.',
'Art. 5 EU AI Act prohibits emotion recognition in the workplace, social scoring and manipulative techniques, among others — since February 2025.',
),
},
{
id: 'q-highrisk',
question: lt(
'Warum gelten für KI im Bewerber-Screening besonders strenge Regeln?',
'Why do particularly strict rules apply to AI in applicant screening?',
),
options: [
lt('Weil sie über Lebenschancen von Menschen mitentscheidet — sie gilt als Hochrisiko-KI.', 'Because it co-decides on peoples life chances — it counts as high-risk AI.'),
lt('Weil sie teuer ist.', 'Because it is expensive.'),
lt('Es gelten keine besonderen Regeln.', 'No special rules apply.'),
],
correctIndex: 0,
explanation: lt(
'Beschäftigungsbezogene KI (Auswahl, Beförderung, Kündigung) steht in Annex III der EU-KI-VO — Hochrisiko mit strengen Pflichten.',
'Employment-related AI (selection, promotion, termination) is listed in Annex III of the EU AI Act — high-risk with strict obligations.',
),
},
{
id: 'q-gdpr',
question: lt(
'KI und personenbezogene Daten — was gilt?',
'AI and personal data — what applies?',
),
options: [
lt('Die DSGVO gilt bei KI nicht.', 'The GDPR does not apply to AI.'),
lt('Die DSGVO gilt weiter: Rechtsgrundlage nötig, Datensparsamkeit, Betroffenenrechte.', 'The GDPR continues to apply: legal basis required, data minimisation, data subject rights.'),
lt('Solange die KI die Daten nur „liest", ist alles erlaubt.', 'As long as the AI only “reads” the data, everything is allowed.'),
],
correctIndex: 1,
explanation: lt(
'KI-Verarbeitung personenbezogener Daten ist Datenverarbeitung im Sinne der DSGVO — mit allen Pflichten, ggf. inkl. Datenschutz-Folgenabschätzung.',
'AI processing of personal data is data processing under the GDPR — with all obligations, possibly including a data protection impact assessment.',
),
},
{
id: 'q-incident',
question: lt(
'Eine KI verhält sich merkwürdig oder diskriminiert offenbar. Was tun Sie?',
'An AI behaves strangely or apparently discriminates. What do you do?',
),
options: [
lt('Weiter nutzen und hoffen, dass es sich gibt.', 'Keep using it and hope it goes away.'),
lt('Sofort intern melden (KI-Beauftragte/r) — es gibt gesetzliche Meldefristen.', 'Report it internally immediately (AI officer) — there are statutory reporting deadlines.'),
lt('Das System heimlich abschalten und niemandem etwas sagen.', 'Secretly switch the system off and tell no one.'),
],
correctIndex: 1,
explanation: lt(
'Für schwerwiegende Vorfälle gelten Meldefristen von bis zu 15 Tagen (Art. 73). Deshalb: sofort intern melden — der Meldeweg steht in der internen Melderichtlinie.',
'Serious incidents are subject to reporting deadlines of up to 15 days (Art. 73). Therefore: report internally at once — the channel is in the internal reporting policy.',
),
},
{
id: 'q-shadow',
question: lt(
'Sie finden ein tolles neues KI-Tool im Internet. Dürfen Sie es für die Arbeit nutzen?',
'You find a great new AI tool on the internet. May you use it for work?',
),
options: [
lt('Ja — was nichts kostet, kann man nutzen.', 'Yes — if it is free, you can use it.'),
lt('Ja, wenn Kolleginnen und Kollegen es auch benutzen.', 'Yes, if colleagues use it too.'),
lt('Erst nach interner Freigabe — sonst entsteht „Schatten-KI" ohne Kontrolle.', 'Only after internal approval — otherwise “shadow AI” arises without control.'),
],
correctIndex: 2,
explanation: lt(
'Nicht freigegebene Tools umgehen Datenschutz-, Sicherheits- und KI-Prüfungen. Es gilt der Freigabeprozess (Freigabeantrag KI-System).',
'Unapproved tools bypass data protection, security and AI checks. The approval process applies (AI system approval request).',
),
},
{
id: 'q-content',
question: lt(
'Sie veröffentlichen einen KI-generierten Text oder ein KI-Bild. Was ist zu beachten?',
'You publish an AI-generated text or image. What must you consider?',
),
options: [
lt('Inhalt fachlich prüfen und KI-generierte Inhalte gemäß Vorgaben kennzeichnen.', 'Review the content professionally and mark AI-generated content according to the rules.'),
lt('Nichts — was die KI erstellt, gehört automatisch dem Unternehmen und stimmt.', 'Nothing — whatever the AI creates automatically belongs to the company and is correct.'),
lt('Nur die Rechtschreibung prüfen.', 'Only check the spelling.'),
],
correctIndex: 0,
explanation: lt(
'KI-Inhalte können Fehler, fremde Rechte oder Verzerrungen enthalten; synthetische Inhalte sind zu kennzeichnen (Art. 50) und Deepfakes sichtbar zu markieren.',
'AI content can contain errors, third-party rights or bias; synthetic content must be marked (Art. 50) and deepfakes visibly labelled.',
),
},
{
id: 'q-art4',
question: lt(
'Warum machen Sie diesen Test überhaupt?',
'Why are you taking this test in the first place?',
),
options: [
lt('Reine Firmen-Schikane.', 'Pure company chicanery.'),
lt('Art. 4 EU-KI-VO verpflichtet Unternehmen seit Februar 2025, KI-Kompetenz ihres Personals sicherzustellen — dieser Test ist Teil des Nachweises.', 'Art. 4 EU AI Act has obliged companies since February 2025 to ensure their staffs AI literacy — this test is part of the evidence.'),
lt('Die KI hat es so angeordnet.', 'The AI ordered it.'),
],
correctIndex: 1,
explanation: lt(
'Die KI-Kompetenzpflicht (Art. 4) gilt für alle Anbieter und Betreiber von KI — unabhängig von der Risikoklasse. Geschulte Beschäftigte sind die wichtigste Schutzmaßnahme.',
'The AI literacy duty (Art. 4) applies to all providers and deployers of AI — regardless of risk class. Trained staff are the most important safeguard.',
),
},
];

View File

@ -44,6 +44,25 @@ export const CS: UIStrings = {
calExport: 'Přidat do kalendáře (.ics)', calExport: 'Přidat do kalendáře (.ics)',
calHint: 'Exportuje všechny otevřené lhůty jako soubor kalendáře s připomínkami (30 a 7 dní předem) — lze importovat do Outlooku, kalendáře Apple i Google, v telefonu i počítači.', calHint: 'Exportuje všechny otevřené lhůty jako soubor kalendáře s připomínkami (30 a 7 dní předem) — lze importovat do Outlooku, kalendáře Apple i Google, v telefonu i počítači.',
navQuiz: 'Test gramotnosti v oblasti AI',
quizSub: 'Jednoduchý test základů (12 otázek) gramotnosti v oblasti AI podle čl. 4 — s tisknutelným certifikátem jako dokladem a místním registrem školení.',
quizName: 'Jméno účastníka/účastnice *',
quizNamePh: 'Jméno a příjmení',
quizSubmit: 'Vyhodnotit test',
quizAnswerAll: 'Zadejte prosím jméno a odpovězte na všechny otázky.',
quizPassed: 'Úspěšně složen',
quizFailed: 'Nesložen',
quizScoreLine: '{correct} z {total} otázek správně ({pct} %) — hranice úspěšnosti {pass} %.',
quizWhy: 'Vysvětlení',
quizCert: 'Vytisknout certifikát / PDF',
quizRetry: 'Opakovat test',
quizLogTitle: 'Registr školení (doklad podle čl. 4)',
quizLogHint: 'Každý pokus se ukládá lokálně a slouží jako doklad povinnosti gramotnosti v oblasti AI (čl. 4). Registr pravidelně tiskněte a zakládejte do dokumentace školení.',
quizLogEmpty: 'Zatím žádné pokusy.',
quizLogPrint: 'Vytisknout registr / PDF',
quizColScore: 'Výsledek',
quizColStatus: 'Stav',
wizardTitleNew: 'Posoudit nový systém AI', wizardTitleNew: 'Posoudit nový systém AI',
wizardTitleRe: 'Nové posouzení: {name}', wizardTitleRe: 'Nové posouzení: {name}',
wizardSub: 'Odpovězte na otázky — riziková třída se určí automaticky podle systematiky aktu o umělé inteligenci.', wizardSub: 'Odpovězte na otázky — riziková třída se určí automaticky podle systematiky aktu o umělé inteligenci.',

View File

@ -44,6 +44,25 @@ export const ES: UIStrings = {
calExport: 'Añadir al calendario (.ics)', calExport: 'Añadir al calendario (.ics)',
calHint: 'Exporta todos los plazos abiertos como archivo de calendario con recordatorios (30 y 7 días antes) — importable en Outlook, Apple y Google Calendar, en el móvil y el ordenador.', calHint: 'Exporta todos los plazos abiertos como archivo de calendario con recordatorios (30 y 7 días antes) — importable en Outlook, Apple y Google Calendar, en el móvil y el ordenador.',
navQuiz: 'Test de alfabetización en IA',
quizSub: 'Test sencillo de fundamentos (12 preguntas) sobre alfabetización en IA según el art. 4 — con certificado imprimible como prueba y registro local de formación.',
quizName: 'Nombre del/de la participante *',
quizNamePh: 'Nombre y apellidos',
quizSubmit: 'Evaluar el test',
quizAnswerAll: 'Introduzca un nombre y responda a todas las preguntas.',
quizPassed: 'Aprobado',
quizFailed: 'No aprobado',
quizScoreLine: '{correct} de {total} preguntas correctas ({pct} %) — umbral de aprobado {pass} %.',
quizWhy: 'Explicación',
quizCert: 'Imprimir certificado / PDF',
quizRetry: 'Repetir el test',
quizLogTitle: 'Registro de formación (prueba art. 4)',
quizLogHint: 'Cada intento se guarda en local y sirve como prueba de la obligación de alfabetización en IA (art. 4). Imprima el registro periódicamente y archívelo con su documentación de formación.',
quizLogEmpty: 'Aún no hay intentos.',
quizLogPrint: 'Imprimir registro / PDF',
quizColScore: 'Resultado',
quizColStatus: 'Estado',
wizardTitleNew: 'Evaluar un nuevo sistema de IA', wizardTitleNew: 'Evaluar un nuevo sistema de IA',
wizardTitleRe: 'Reevaluación: {name}', wizardTitleRe: 'Reevaluación: {name}',
wizardSub: 'Responda a las preguntas — la clase de riesgo se determina automáticamente según la sistemática del Reglamento de IA.', wizardSub: 'Responda a las preguntas — la clase de riesgo se determina automáticamente según la sistemática del Reglamento de IA.',

View File

@ -44,6 +44,25 @@ export const FR: UIStrings = {
calExport: 'Ajouter au calendrier (.ics)', calExport: 'Ajouter au calendrier (.ics)',
calHint: 'Exporte toutes les échéances ouvertes sous forme de fichier calendrier avec rappels (30 et 7 jours avant) — importable dans Outlook, Apple et Google Agenda, sur téléphone et ordinateur.', calHint: 'Exporte toutes les échéances ouvertes sous forme de fichier calendrier avec rappels (30 et 7 jours avant) — importable dans Outlook, Apple et Google Agenda, sur téléphone et ordinateur.',
navQuiz: 'Test de maîtrise de lIA',
quizSub: 'Test simple des fondamentaux (12 questions) sur la maîtrise de lIA selon lart. 4 — avec certificat imprimable comme preuve et registre local de formation.',
quizName: 'Nom du participant / de la participante *',
quizNamePh: 'Prénom et nom',
quizSubmit: 'Évaluer le test',
quizAnswerAll: 'Veuillez saisir un nom et répondre à toutes les questions.',
quizPassed: 'Réussi',
quizFailed: 'Non réussi',
quizScoreLine: '{correct} questions sur {total} correctes ({pct} %) — seuil de réussite {pass} %.',
quizWhy: 'Explication',
quizCert: 'Imprimer le certificat / PDF',
quizRetry: 'Repasser le test',
quizLogTitle: 'Registre de formation (preuve art. 4)',
quizLogHint: 'Chaque passage est enregistré en local et sert de preuve de lobligation de maîtrise de lIA (art. 4). Imprimez régulièrement le registre et classez-le avec votre documentation de formation.',
quizLogEmpty: 'Aucun passage pour linstant.',
quizLogPrint: 'Imprimer le registre / PDF',
quizColScore: 'Résultat',
quizColStatus: 'Statut',
wizardTitleNew: 'Évaluer un nouveau système dIA', wizardTitleNew: 'Évaluer un nouveau système dIA',
wizardTitleRe: 'Réévaluation : {name}', wizardTitleRe: 'Réévaluation : {name}',
wizardSub: 'Répondez aux questions — la classe de risque est déterminée automatiquement selon la systématique du règlement IA.', wizardSub: 'Répondez aux questions — la classe de risque est déterminée automatiquement selon la systématique du règlement IA.',

View File

@ -44,6 +44,25 @@ export const IT: UIStrings = {
calExport: 'Aggiungi al calendario (.ics)', calExport: 'Aggiungi al calendario (.ics)',
calHint: 'Esporta tutte le scadenze aperte come file di calendario con promemoria (30 e 7 giorni prima) — importabile in Outlook, Apple e Google Calendar, su telefono e computer.', calHint: 'Esporta tutte le scadenze aperte come file di calendario con promemoria (30 e 7 giorni prima) — importabile in Outlook, Apple e Google Calendar, su telefono e computer.',
navQuiz: 'Test di alfabetizzazione IA',
quizSub: 'Test semplice sulle basi (12 domande) dellalfabetizzazione IA ai sensi dellart. 4 — con certificato stampabile come prova e registro locale della formazione.',
quizName: 'Nome del/della partecipante *',
quizNamePh: 'Nome e cognome',
quizSubmit: 'Valuta il test',
quizAnswerAll: 'Inserite un nome e rispondete a tutte le domande.',
quizPassed: 'Superato',
quizFailed: 'Non superato',
quizScoreLine: '{correct} domande su {total} corrette ({pct} %) — soglia di superamento {pass} %.',
quizWhy: 'Spiegazione',
quizCert: 'Stampa certificato / PDF',
quizRetry: 'Ripeti il test',
quizLogTitle: 'Registro formazione (prova art. 4)',
quizLogHint: 'Ogni esecuzione viene salvata in locale e serve come prova dellobbligo di alfabetizzazione IA (art. 4). Stampate regolarmente il registro e archiviatelo con la documentazione formativa.',
quizLogEmpty: 'Nessuna esecuzione finora.',
quizLogPrint: 'Stampa registro / PDF',
quizColScore: 'Risultato',
quizColStatus: 'Stato',
wizardTitleNew: 'Valutare un nuovo sistema di IA', wizardTitleNew: 'Valutare un nuovo sistema di IA',
wizardTitleRe: 'Rivalutazione: {name}', wizardTitleRe: 'Rivalutazione: {name}',
wizardSub: 'Rispondete alle domande — la classe di rischio viene determinata automaticamente secondo la sistematica del regolamento IA.', wizardSub: 'Rispondete alle domande — la classe di rischio viene determinata automaticamente secondo la sistematica del regolamento IA.',

View File

@ -44,6 +44,25 @@ export const PL: UIStrings = {
calExport: 'Dodaj do kalendarza (.ics)', calExport: 'Dodaj do kalendarza (.ics)',
calHint: 'Eksportuje wszystkie otwarte terminy jako plik kalendarza z przypomnieniami (30 i 7 dni wcześniej) — do zaimportowania w Outlooku, kalendarzu Apple i Google, na telefonie i komputerze.', calHint: 'Eksportuje wszystkie otwarte terminy jako plik kalendarza z przypomnieniami (30 i 7 dni wcześniej) — do zaimportowania w Outlooku, kalendarzu Apple i Google, na telefonie i komputerze.',
navQuiz: 'Test kompetencji AI',
quizSub: 'Prosty test podstaw (12 pytań) z kompetencji w zakresie AI zgodnie z art. 4 — z drukowanym certyfikatem jako dowodem i lokalnym rejestrem szkoleń.',
quizName: 'Imię i nazwisko uczestnika/uczestniczki *',
quizNamePh: 'Imię i nazwisko',
quizSubmit: 'Oceń test',
quizAnswerAll: 'Proszę podać imię i nazwisko oraz odpowiedzieć na wszystkie pytania.',
quizPassed: 'Zaliczony',
quizFailed: 'Niezaliczony',
quizScoreLine: '{correct} z {total} pytań poprawnie ({pct} %) — próg zaliczenia {pass} %.',
quizWhy: 'Wyjaśnienie',
quizCert: 'Drukuj certyfikat / PDF',
quizRetry: 'Powtórz test',
quizLogTitle: 'Rejestr szkoleń (dowód z art. 4)',
quizLogHint: 'Każde podejście jest zapisywane lokalnie i służy jako dowód obowiązku kompetencji w zakresie AI (art. 4). Regularnie drukuj rejestr i dołączaj do dokumentacji szkoleniowej.',
quizLogEmpty: 'Brak podejść.',
quizLogPrint: 'Drukuj rejestr / PDF',
quizColScore: 'Wynik',
quizColStatus: 'Status',
wizardTitleNew: 'Oceń nowy system AI', wizardTitleNew: 'Oceń nowy system AI',
wizardTitleRe: 'Ponowna ocena: {name}', wizardTitleRe: 'Ponowna ocena: {name}',
wizardSub: 'Odpowiedz na pytania — klasa ryzyka zostanie ustalona automatycznie zgodnie z systematyką rozporządzenia w sprawie AI.', wizardSub: 'Odpowiedz na pytania — klasa ryzyka zostanie ustalona automatycznie zgodnie z systematyką rozporządzenia w sprawie AI.',

View File

@ -44,6 +44,25 @@ export const RO: UIStrings = {
calExport: 'Adaugă în calendar (.ics)', calExport: 'Adaugă în calendar (.ics)',
calHint: 'Exportă toate termenele deschise ca fișier de calendar cu mementouri (cu 30 și 7 zile înainte) — importabil în Outlook, Apple și Google Calendar, pe telefon și pe computer.', calHint: 'Exportă toate termenele deschise ca fișier de calendar cu mementouri (cu 30 și 7 zile înainte) — importabil în Outlook, Apple și Google Calendar, pe telefon și pe computer.',
navQuiz: 'Test de alfabetizare în IA',
quizSub: 'Test simplu de bază (12 întrebări) privind alfabetizarea în domeniul IA conform art. 4 — cu certificat imprimabil ca dovadă și registru local de formare.',
quizName: 'Numele participantului/participantei *',
quizNamePh: 'Prenume și nume',
quizSubmit: 'Evaluează testul',
quizAnswerAll: 'Vă rugăm să introduceți numele și să răspundeți la toate întrebările.',
quizPassed: 'Promovat',
quizFailed: 'Nepromovat',
quizScoreLine: '{correct} din {total} întrebări corecte ({pct} %) — prag de promovare {pass} %.',
quizWhy: 'Explicație',
quizCert: 'Tipărește certificatul / PDF',
quizRetry: 'Repetă testul',
quizLogTitle: 'Registrul de formare (dovadă art. 4)',
quizLogHint: 'Fiecare încercare este salvată local și servește drept dovadă a obligației de alfabetizare în IA (art. 4). Tipăriți regulat registrul și arhivați-l cu documentația de formare.',
quizLogEmpty: 'Nicio încercare până acum.',
quizLogPrint: 'Tipărește registrul / PDF',
quizColScore: 'Rezultat',
quizColStatus: 'Stare',
wizardTitleNew: 'Evaluează un sistem de IA nou', wizardTitleNew: 'Evaluează un sistem de IA nou',
wizardTitleRe: 'Reevaluare: {name}', wizardTitleRe: 'Reevaluare: {name}',
wizardSub: 'Răspundeți la întrebări — clasa de risc este determinată automat conform sistematicii Regulamentului privind IA.', wizardSub: 'Răspundeți la întrebări — clasa de risc este determinată automat conform sistematicii Regulamentului privind IA.',

View File

@ -44,6 +44,25 @@ export const SK: UIStrings = {
calExport: 'Pridať do kalendára (.ics)', calExport: 'Pridať do kalendára (.ics)',
calHint: 'Exportuje všetky otvorené lehoty ako súbor kalendára s pripomienkami (30 a 7 dní vopred) — možno importovať do Outlooku, kalendára Apple aj Google, v telefóne aj počítači.', calHint: 'Exportuje všetky otvorené lehoty ako súbor kalendára s pripomienkami (30 a 7 dní vopred) — možno importovať do Outlooku, kalendára Apple aj Google, v telefóne aj počítači.',
navQuiz: 'Test gramotnosti v oblasti AI',
quizSub: 'Jednoduchý test základov (12 otázok) gramotnosti v oblasti AI podľa čl. 4 — s tlačiteľným certifikátom ako dokladom a lokálnym registrom školení.',
quizName: 'Meno účastníka/účastníčky *',
quizNamePh: 'Meno a priezvisko',
quizSubmit: 'Vyhodnotiť test',
quizAnswerAll: 'Zadajte, prosím, meno a odpovedzte na všetky otázky.',
quizPassed: 'Úspešne absolvovaný',
quizFailed: 'Neabsolvovaný',
quizScoreLine: '{correct} z {total} otázok správne ({pct} %) — hranica úspešnosti {pass} %.',
quizWhy: 'Vysvetlenie',
quizCert: 'Vytlačiť certifikát / PDF',
quizRetry: 'Zopakovať test',
quizLogTitle: 'Register školení (doklad podľa čl. 4)',
quizLogHint: 'Každý pokus sa ukladá lokálne a slúži ako doklad povinnosti gramotnosti v oblasti AI (čl. 4). Register pravidelne tlačte a zakladajte do dokumentácie školení.',
quizLogEmpty: 'Zatiaľ žiadne pokusy.',
quizLogPrint: 'Vytlačiť register / PDF',
quizColScore: 'Výsledok',
quizColStatus: 'Stav',
wizardTitleNew: 'Posúdiť nový systém AI', wizardTitleNew: 'Posúdiť nový systém AI',
wizardTitleRe: 'Opätovné posúdenie: {name}', wizardTitleRe: 'Opätovné posúdenie: {name}',
wizardSub: 'Odpovedzte na otázky — riziková trieda sa určí automaticky podľa systematiky aktu o umelej inteligencii.', wizardSub: 'Odpovedzte na otázky — riziková trieda sa určí automaticky podľa systematiky aktu o umelej inteligencii.',

View File

@ -44,6 +44,25 @@ const DE = {
calExport: 'In Kalender übernehmen (.ics)', calExport: 'In Kalender übernehmen (.ics)',
calHint: 'Exportiert alle offenen Fristen als Kalenderdatei mit Erinnerungen (30 und 7 Tage vorher) — importierbar in Outlook, Apple- und Google-Kalender auf Handy und Rechner.', calHint: 'Exportiert alle offenen Fristen als Kalenderdatei mit Erinnerungen (30 und 7 Tage vorher) — importierbar in Outlook, Apple- und Google-Kalender auf Handy und Rechner.',
navQuiz: 'KI-Kompetenztest',
quizSub: 'Einfacher Grundlagentest (12 Fragen) zur KI-Kompetenz nach Art. 4 — mit druckbarem Zertifikat als Nachweis und lokalem Schulungsregister.',
quizName: 'Name der Teilnehmerin / des Teilnehmers *',
quizNamePh: 'Vor- und Nachname',
quizSubmit: 'Test auswerten',
quizAnswerAll: 'Bitte Namen eintragen und alle Fragen beantworten.',
quizPassed: 'Bestanden',
quizFailed: 'Nicht bestanden',
quizScoreLine: '{correct} von {total} Fragen richtig ({pct} %) — Bestehensgrenze {pass} %.',
quizWhy: 'Erklärung',
quizCert: 'Zertifikat drucken / PDF',
quizRetry: 'Test wiederholen',
quizLogTitle: 'Schulungsregister (Art.-4-Nachweis)',
quizLogHint: 'Jeder Testdurchlauf wird lokal gespeichert und dient als Nachweis der KI-Kompetenzpflicht (Art. 4). Register regelmäßig drucken und zur Schulungsdokumentation nehmen.',
quizLogEmpty: 'Noch keine Testdurchläufe.',
quizLogPrint: 'Register drucken / PDF',
quizColScore: 'Ergebnis',
quizColStatus: 'Status',
wizardTitleNew: 'Neues KI-System bewerten', wizardTitleNew: 'Neues KI-System bewerten',
wizardTitleRe: 'Neubewertung: {name}', wizardTitleRe: 'Neubewertung: {name}',
wizardSub: 'Beantworten Sie die Fragen — die Risikoklasse wird automatisch nach der Systematik des AI Act ermittelt.', wizardSub: 'Beantworten Sie die Fragen — die Risikoklasse wird automatisch nach der Systematik des AI Act ermittelt.',
@ -219,6 +238,25 @@ const EN: UIStrings = {
calExport: 'Add to calendar (.ics)', calExport: 'Add to calendar (.ics)',
calHint: 'Exports all open deadlines as a calendar file with reminders (30 and 7 days ahead) — importable into Outlook, Apple and Google Calendar on phone and desktop.', calHint: 'Exports all open deadlines as a calendar file with reminders (30 and 7 days ahead) — importable into Outlook, Apple and Google Calendar on phone and desktop.',
navQuiz: 'AI literacy test',
quizSub: 'Simple basics test (12 questions) on AI literacy under Art. 4 — with printable certificate as evidence and a local training register.',
quizName: 'Participant name *',
quizNamePh: 'First and last name',
quizSubmit: 'Evaluate test',
quizAnswerAll: 'Please enter a name and answer all questions.',
quizPassed: 'Passed',
quizFailed: 'Not passed',
quizScoreLine: '{correct} of {total} questions correct ({pct} %) — pass threshold {pass} %.',
quizWhy: 'Explanation',
quizCert: 'Print certificate / PDF',
quizRetry: 'Retake test',
quizLogTitle: 'Training register (Art. 4 evidence)',
quizLogHint: 'Each test run is stored locally and serves as evidence of the AI literacy duty (Art. 4). Print the register regularly and file it with your training documentation.',
quizLogEmpty: 'No test runs yet.',
quizLogPrint: 'Print register / PDF',
quizColScore: 'Score',
quizColStatus: 'Status',
wizardTitleNew: 'Assess a new AI system', wizardTitleNew: 'Assess a new AI system',
wizardTitleRe: 'Reassessment: {name}', wizardTitleRe: 'Reassessment: {name}',
wizardSub: 'Answer the questions — the risk class is determined automatically following the AI Acts system.', wizardSub: 'Answer the questions — the risk class is determined automatically following the AI Acts system.',

View File

@ -1,7 +1,26 @@
import type { TrainingRecord } from '../engine/quiz';
import type { AISystem, CompanyProfile } from '../engine/types'; import type { AISystem, CompanyProfile } from '../engine/types';
const STORAGE_KEY = 'ai-act-kompass/systems/v1'; const STORAGE_KEY = 'ai-act-kompass/systems/v1';
const PROFILE_KEY = 'ai-act-kompass/profile/v1'; const PROFILE_KEY = 'ai-act-kompass/profile/v1';
const TRAINING_KEY = 'ai-act-kompass/training/v1';
/** Lädt das Schulungsregister aus dem lokalen Speicher (fail-safe). */
export function loadTraining(): readonly TrainingRecord[] {
try {
const raw = localStorage.getItem(TRAINING_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as TrainingRecord[]) : [];
} catch {
return [];
}
}
/** Persistiert das Schulungsregister im lokalen Speicher. */
export function saveTraining(records: readonly TrainingRecord[]): void {
localStorage.setItem(TRAINING_KEY, JSON.stringify(records));
}
/** Format der Export-/Import-Datei. */ /** Format der Export-/Import-Datei. */
export interface ExportFile { export interface ExportFile {
@ -127,4 +146,5 @@ export function importData(json: string): ImportResult {
export function clearAll(): void { export function clearAll(): void {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(PROFILE_KEY); localStorage.removeItem(PROFILE_KEY);
localStorage.removeItem(TRAINING_KEY);
} }

View File

@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { TrainingRecord } from '../engine/quiz';
import type { AISystem, CompanyProfile, ObligationStatus } from '../engine/types'; import type { AISystem, CompanyProfile, ObligationStatus } from '../engine/types';
import { useI18n } from '../i18n/context'; import { useI18n } from '../i18n/context';
import { LOCALES } from '../i18n/types'; import { LOCALES } from '../i18n/types';
import { fmt } from '../i18n/ui'; import { fmt } from '../i18n/ui';
import { loadProfile, loadSystems, saveProfile, saveSystems } from '../store/storage'; import { loadProfile, loadSystems, loadTraining, saveProfile, saveSystems, saveTraining } from '../store/storage';
import { ChecklistView } from './ChecklistView'; import { ChecklistView } from './ChecklistView';
import { Dashboard } from './Dashboard'; import { Dashboard } from './Dashboard';
import { DisclaimerGate } from './DisclaimerGate'; import { DisclaimerGate } from './DisclaimerGate';
import { QuizView } from './QuizView';
import { SettingsView } from './SettingsView'; import { SettingsView } from './SettingsView';
import { SystemDetail } from './SystemDetail'; import { SystemDetail } from './SystemDetail';
import { TemplatesView } from './TemplatesView'; import { TemplatesView } from './TemplatesView';
@ -16,6 +18,7 @@ type View =
| { readonly kind: 'dashboard' } | { readonly kind: 'dashboard' }
| { readonly kind: 'tasks' } | { readonly kind: 'tasks' }
| { readonly kind: 'templates' } | { readonly kind: 'templates' }
| { readonly kind: 'quiz' }
| { readonly kind: 'wizard'; readonly systemId: string | null } | { readonly kind: 'wizard'; readonly systemId: string | null }
| { readonly kind: 'detail'; readonly systemId: string } | { readonly kind: 'detail'; readonly systemId: string }
| { readonly kind: 'settings' }; | { readonly kind: 'settings' };
@ -25,6 +28,7 @@ export function App(): JSX.Element {
const { t, locale, setLocale } = useI18n(); const { t, locale, setLocale } = useI18n();
const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems()); const [systems, setSystems] = useState<readonly AISystem[]>(() => loadSystems());
const [profile, setProfile] = useState<CompanyProfile>(() => loadProfile()); const [profile, setProfile] = useState<CompanyProfile>(() => loadProfile());
const [training, setTraining] = useState<readonly TrainingRecord[]>(() => loadTraining());
const [view, setView] = useState<View>({ kind: 'dashboard' }); const [view, setView] = useState<View>({ kind: 'dashboard' });
useEffect(() => { useEffect(() => {
@ -35,6 +39,10 @@ export function App(): JSX.Element {
saveProfile(profile); saveProfile(profile);
}, [profile]); }, [profile]);
useEffect(() => {
saveTraining(training);
}, [training]);
const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id); const findSystem = (id: string): AISystem | undefined => systems.find((s) => s.id === id);
const upsert = (system: AISystem): void => { const upsert = (system: AISystem): void => {
@ -96,6 +104,12 @@ export function App(): JSX.Element {
> >
{t.navTemplates} {t.navTemplates}
</button> </button>
<button
className={view.kind === 'quiz' ? 'active' : ''}
onClick={() => setView({ kind: 'quiz' })}
>
{t.navQuiz}
</button>
<button <button
className={view.kind === 'settings' ? 'active' : ''} className={view.kind === 'settings' ? 'active' : ''}
onClick={() => setView({ kind: 'settings' })} onClick={() => setView({ kind: 'settings' })}
@ -148,6 +162,14 @@ export function App(): JSX.Element {
{view.kind === 'templates' && <TemplatesView systems={systems} profile={profile} />} {view.kind === 'templates' && <TemplatesView systems={systems} profile={profile} />}
{view.kind === 'quiz' && (
<QuizView
profile={profile}
records={training}
onRecord={(record) => setTraining((prev) => [record, ...prev])}
/>
)}
{view.kind === 'wizard' && ( {view.kind === 'wizard' && (
<Wizard <Wizard
existing={view.systemId ? (findSystem(view.systemId) ?? null) : null} existing={view.systemId ? (findSystem(view.systemId) ?? null) : null}
@ -190,7 +212,10 @@ export function App(): JSX.Element {
setSystems(imported); setSystems(imported);
if (importedProfile) setProfile(importedProfile); if (importedProfile) setProfile(importedProfile);
}} }}
onClear={() => setSystems([])} onClear={() => {
setSystems([]);
setTraining([]);
}}
/> />
)} )}
</main> </main>

View File

@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import { certificateDoc, getQuiz, scoreQuiz, trainingLogDoc } from '../engine/quiz';
import type { QuizScore, TrainingRecord } from '../engine/quiz';
import { QUIZ_PASS_PCT } from '../engine/quizContent';
import type { CompanyProfile } from '../engine/types';
import { useI18n } from '../i18n/context';
import { fmt } from '../i18n/ui';
import { printDocument } from '../print/print';
import { todayISO, uiDate } from './common';
interface QuizViewProps {
readonly profile: CompanyProfile;
readonly records: readonly TrainingRecord[];
readonly onRecord: (record: TrainingRecord) => void;
}
/**
* KI-Kompetenztest (Art. 4): einfacher Grundlagentest mit Auswertung,
* druckbarem Zertifikat und lokalem Schulungsregister als Nachweis.
*/
export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Element {
const { t, locale, pack } = useI18n();
const today = todayISO();
const questions = useMemo(() => getQuiz(locale), [locale]);
const [name, setName] = useState('');
const [answers, setAnswers] = useState<readonly (number | null)[]>(() =>
questions.map(() => null),
);
const [result, setResult] = useState<QuizScore | null>(null);
const complete = name.trim() !== '' && answers.every((a) => a !== null);
const submit = (): void => {
const score = scoreQuiz(answers);
setResult(score);
onRecord({
id: crypto.randomUUID(),
name: name.trim(),
date: today,
scorePct: score.pct,
passed: score.passed,
});
};
const reset = (): void => {
setAnswers(questions.map(() => null));
setResult(null);
};
return (
<div>
<h1>{t.navQuiz}</h1>
<p className="sub">{t.quizSub}</p>
{result === null ? (
<>
<div className="panel">
<label className="field">
<span>{t.quizName}</span>
<input
type="text"
value={name}
placeholder={t.quizNamePh}
onChange={(e) => setName(e.target.value)}
/>
</label>
</div>
{questions.map((q, qi) => (
<div key={q.id} className="panel">
<h2 style={{ marginTop: 0 }}>
{qi + 1}. {q.question}
</h2>
{q.options.map((option, oi) => (
<label key={oi} className="check">
<input
type="radio"
name={q.id}
checked={answers[qi] === oi}
onChange={() =>
setAnswers(answers.map((a, i) => (i === qi ? oi : a)))
}
/>
<span>{option}</span>
</label>
))}
</div>
))}
<div className="wizard-nav">
<span className="hint">{!complete ? t.quizAnswerAll : ''}</span>
<button className="primary" disabled={!complete} onClick={submit}>
{t.quizSubmit}
</button>
</div>
</>
) : (
<div className="panel">
<h2 style={{ marginTop: 0 }}>
<span className={`badge ${result.passed ? 'minimal' : 'high'}`}>
{result.passed ? t.quizPassed : t.quizFailed}
</span>
</h2>
<p>
{fmt(t.quizScoreLine, {
correct: result.correct,
total: result.total,
pct: result.pct,
pass: QUIZ_PASS_PCT,
})}
</p>
<div className="toolbar">
<button
className="primary"
onClick={() =>
printDocument(
`ki-kompetenz-${name.trim()}-${today}`,
certificateDoc(name.trim(), result, pack, today, profile, locale),
profile,
)
}
>
{t.quizCert}
</button>
<button className="ghost" onClick={reset}>
{t.quizRetry}
</button>
</div>
<h2>{t.quizWhy}</h2>
<ul className="reasons">
{questions.map((q, qi) => (
<li key={q.id}>
<strong>
{answers[qi] === q.correctIndex ? '✓' : '✗'} {qi + 1}.
</strong>{' '}
{q.explanation}
</li>
))}
</ul>
</div>
)}
<div className="panel">
<div className="panel-head">
<h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2>
{records.length > 0 && (
<button
className="ghost"
onClick={() =>
printDocument(
`schulungsregister-${today}`,
trainingLogDoc(records, pack, today, profile, locale),
profile,
)
}
>
{t.quizLogPrint}
</button>
)}
</div>
<p className="hint">{t.quizLogHint}</p>
{records.length === 0 ? (
<p className="hint">{t.quizLogEmpty}</p>
) : (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>{t.colUpdated}</th>
<th>{t.colOwner}</th>
<th>{t.quizColScore}</th>
<th>{t.quizColStatus}</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id}>
<td>{uiDate(r.date, locale)}</td>
<td>{r.name}</td>
<td>{r.scorePct} %</td>
<td>
<span className={`badge ${r.passed ? 'minimal' : 'high'}`}>
{r.passed ? t.quizPassed : t.quizFailed}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}