feat(ai-act-kompass): verified evidence chain for the AI literacy test
- Manual participant entry for paper tests: name + correct answers → register entry with source 'paper' (app runs are tagged 'app') - Scan upload per register entry: marked paper sheets (image/PDF, max 1 MB) stored locally as data URL and anchored with a SHA-256 checksum of the original file; view scan in-app, checksum shown - Document verification: certificate and training register printouts carry a SHA-256 checksum footer over the document content — a presented printout can be re-verified against the tool; certificate additionally references its register entry ID (evidence chain certificate ↔ register ↔ scan) - Register printout extended with source, evidence checksum and ID columns; record deletion with confirmation warning - UI labels in all nine languages; 73 tests green Claude-Session: https://claude.ai/code/session_017YjohVNx1ZGNM4MX7tHpfv
This commit is contained in:
parent
c35318a240
commit
dc09999472
@ -104,10 +104,18 @@ describe('KI-Kompetenztest', () => {
|
|||||||
expect(en).not.toBe(de);
|
expect(en).not.toBe(de);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erzeugt das Schulungsregister mit allen Durchläufen', () => {
|
it('erzeugt das Schulungsregister mit Quelle, Beleg-Prüfsumme und ID', () => {
|
||||||
const md = trainingLogDoc(
|
const md = trainingLogDoc(
|
||||||
[
|
[
|
||||||
{ id: '1', name: 'Max Mustermann', date: '2026-07-04', scorePct: 92, passed: true },
|
{
|
||||||
|
id: 'abcdef12-3456',
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
date: '2026-07-04',
|
||||||
|
scorePct: 92,
|
||||||
|
passed: true,
|
||||||
|
source: 'paper',
|
||||||
|
attachment: { fileName: 'scan.pdf', sha256: 'f'.repeat(64), dataUrl: 'data:application/pdf;base64,x' },
|
||||||
|
},
|
||||||
{ id: '2', name: 'Erika Musterfrau', date: '2026-07-03', scorePct: 58, passed: false },
|
{ id: '2', name: 'Erika Musterfrau', date: '2026-07-03', scorePct: 58, passed: false },
|
||||||
],
|
],
|
||||||
CONTENT_PACK,
|
CONTENT_PACK,
|
||||||
@ -115,8 +123,17 @@ describe('KI-Kompetenztest', () => {
|
|||||||
profile,
|
profile,
|
||||||
);
|
);
|
||||||
expect(md).toContain('Schulungsregister');
|
expect(md).toContain('Schulungsregister');
|
||||||
expect(md).toContain('Max Mustermann');
|
expect(md).toContain('| Papier |'); // Quelle Papier-Test
|
||||||
|
expect(md).toContain('| App |'); // Alt-Einträge ohne source = App
|
||||||
|
expect(md).toContain('`ffffffffffffffff…`'); // Beleg-Prüfsumme (gekürzt)
|
||||||
|
expect(md).toContain('`abcdef12`'); // Register-ID (gekürzt)
|
||||||
expect(md).toContain('NICHT BESTANDEN');
|
expect(md).toContain('NICHT BESTANDEN');
|
||||||
expect(md).toContain('70 %');
|
});
|
||||||
|
|
||||||
|
it('Zertifikat enthält die Register-ID für die Nachweiskette', () => {
|
||||||
|
const score = scoreQuiz(QUIZ_QUESTIONS.map((q) => q.correctIndex));
|
||||||
|
const md = certificateDoc('Max', score, CONTENT_PACK, '2026-07-04', profile, 'de', 'abcdef12-3456-7890');
|
||||||
|
expect(md).toContain('Register-ID');
|
||||||
|
expect(md).toContain('`abcdef12`');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -21,6 +21,15 @@ export interface QuizScore {
|
|||||||
readonly passed: boolean;
|
readonly passed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Beleg-Anhang eines Registereintrags (z. B. eingescannter Testbogen). */
|
||||||
|
export interface TrainingAttachment {
|
||||||
|
readonly fileName: string;
|
||||||
|
/** SHA-256-Prüfsumme der Originaldatei (Hex). */
|
||||||
|
readonly sha256: string;
|
||||||
|
/** Dateiinhalt als Data-URL — bleibt lokal im Browser. */
|
||||||
|
readonly dataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Eintrag im lokalen Schulungsregister (Art.-4-Nachweis). */
|
/** Eintrag im lokalen Schulungsregister (Art.-4-Nachweis). */
|
||||||
export interface TrainingRecord {
|
export interface TrainingRecord {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@ -28,6 +37,10 @@ export interface TrainingRecord {
|
|||||||
readonly date: string;
|
readonly date: string;
|
||||||
readonly scorePct: number;
|
readonly scorePct: number;
|
||||||
readonly passed: boolean;
|
readonly passed: boolean;
|
||||||
|
/** Durchführung in der App oder auf Papier (Alt-Einträge: App). */
|
||||||
|
readonly source?: 'app' | 'paper';
|
||||||
|
/** Eingescannter, ausgewerteter Testbogen als Beleg. */
|
||||||
|
readonly attachment?: TrainingAttachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liefert den Fragenkatalog in der gewünschten Sprache. */
|
/** Liefert den Fragenkatalog in der gewünschten Sprache. */
|
||||||
@ -75,6 +88,8 @@ interface CertStrings {
|
|||||||
readonly logTitle: string;
|
readonly logTitle: string;
|
||||||
readonly logIntro: string;
|
readonly logIntro: string;
|
||||||
readonly logCols: string;
|
readonly logCols: string;
|
||||||
|
readonly sourceApp: string;
|
||||||
|
readonly sourcePaper: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
|
const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
|
||||||
@ -95,7 +110,9 @@ const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
|
|||||||
signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________',
|
signature: 'Ort, Datum: _______________ Unterschrift Beschäftigte/r: _______________ Unterschrift Arbeitgeber: _______________',
|
||||||
logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)',
|
logTitle: 'Schulungsregister KI-Kompetenz (Art. 4 EU-KI-VO)',
|
||||||
logIntro: 'Fortlaufender Nachweis der durchgeführten KI-Kompetenztests. Bestehensgrenze: {pct} %.',
|
logIntro: 'Fortlaufender Nachweis der durchgeführten KI-Kompetenztests. Bestehensgrenze: {pct} %.',
|
||||||
logCols: '| Datum | Name | Ergebnis | Status |',
|
logCols: '| Datum | Name | Quelle | Ergebnis | Status | Beleg (SHA-256) | ID |',
|
||||||
|
sourceApp: 'App',
|
||||||
|
sourcePaper: 'Papier',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: 'Evidence of AI literacy (Art. 4 EU AI Act)',
|
title: 'Evidence of AI literacy (Art. 4 EU AI Act)',
|
||||||
@ -114,7 +131,9 @@ const CERT: Readonly<Record<'de' | 'en', CertStrings>> = {
|
|||||||
signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________',
|
signature: 'Place, date: _______________ Signature employee: _______________ Signature employer: _______________',
|
||||||
logTitle: 'AI literacy training register (Art. 4 EU AI Act)',
|
logTitle: 'AI literacy training register (Art. 4 EU AI Act)',
|
||||||
logIntro: 'Continuous record of completed AI literacy tests. Pass threshold: {pct} %.',
|
logIntro: 'Continuous record of completed AI literacy tests. Pass threshold: {pct} %.',
|
||||||
logCols: '| Date | Name | Score | Status |',
|
logCols: '| Date | Name | Source | Score | Status | Evidence (SHA-256) | ID |',
|
||||||
|
sourceApp: 'App',
|
||||||
|
sourcePaper: 'Paper',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -131,6 +150,7 @@ function certStrings(locale: Locale): CertStrings {
|
|||||||
* @param today - Datum im ISO-Format
|
* @param today - Datum im ISO-Format
|
||||||
* @param profile - Firmenprofil (Briefkopf)
|
* @param profile - Firmenprofil (Briefkopf)
|
||||||
* @param locale - Dokumentsprache
|
* @param locale - Dokumentsprache
|
||||||
|
* @param recordId - ID des zugehörigen Registereintrags (Nachweiskette)
|
||||||
*/
|
*/
|
||||||
export function certificateDoc(
|
export function certificateDoc(
|
||||||
name: string,
|
name: string,
|
||||||
@ -139,6 +159,7 @@ export function certificateDoc(
|
|||||||
today: string,
|
today: string,
|
||||||
profile?: CompanyProfile,
|
profile?: CompanyProfile,
|
||||||
locale: Locale = 'de',
|
locale: Locale = 'de',
|
||||||
|
recordId?: string,
|
||||||
): string {
|
): string {
|
||||||
const s = docStrings(locale);
|
const s = docStrings(locale);
|
||||||
const c = certStrings(locale);
|
const c = certStrings(locale);
|
||||||
@ -157,6 +178,7 @@ export function certificateDoc(
|
|||||||
`| **${c.name}** | ${name} |`,
|
`| **${c.name}** | ${name} |`,
|
||||||
`| **${c.date}** | ${formatDocDate(today, locale)} |`,
|
`| **${c.date}** | ${formatDocDate(today, locale)} |`,
|
||||||
`| **${c.result}** | ${score.correct}/${score.total} (${score.pct} %) — **${score.passed ? c.resultPassed : c.resultFailed}** |`,
|
`| **${c.result}** | ${score.correct}/${score.total} (${score.pct} %) — **${score.passed ? c.resultPassed : c.resultFailed}** |`,
|
||||||
|
...(recordId ? [`| **${locale === 'de' ? 'Register-ID' : 'Register ID'}** | \`${recordId.slice(0, 8)}\` |`] : []),
|
||||||
'',
|
'',
|
||||||
`## ${c.topics}`,
|
`## ${c.topics}`,
|
||||||
'',
|
'',
|
||||||
@ -349,11 +371,15 @@ export function trainingLogDoc(
|
|||||||
c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)),
|
c.logIntro.replace('{pct}', String(QUIZ_PASS_PCT)),
|
||||||
'',
|
'',
|
||||||
c.logCols,
|
c.logCols,
|
||||||
'|---|---|---|---|',
|
'|---|---|---|---|---|---|---|',
|
||||||
];
|
];
|
||||||
for (const r of records) {
|
for (const r of records) {
|
||||||
|
const source = (r.source ?? 'app') === 'paper' ? c.sourcePaper : c.sourceApp;
|
||||||
|
const evidence = r.attachment ? `\`${r.attachment.sha256.slice(0, 16)}…\`` : '—';
|
||||||
lines.push(
|
lines.push(
|
||||||
`| ${formatDocDate(r.date, locale)} | ${r.name} | ${r.scorePct} % | ${r.passed ? c.resultPassed : c.resultFailed} |`,
|
`| ${formatDocDate(r.date, locale)} | ${r.name} | ${source} | ${r.scorePct} % | ${
|
||||||
|
r.passed ? c.resultPassed : c.resultFailed
|
||||||
|
} | ${evidence} | \`${r.id.slice(0, 8)}\` |`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
|
|||||||
53
ai-act-kompass/src/engine/verify.ts
Normal file
53
ai-act-kompass/src/engine/verify.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { Locale } from '../i18n/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dokument-Verifikation für Nachweise.
|
||||||
|
*
|
||||||
|
* Gedruckte Nachweise (Zertifikat, Schulungsregister) erhalten eine
|
||||||
|
* SHA-256-Prüfsumme über den Dokumentinhalt. Damit lässt sich später
|
||||||
|
* belegen, dass ein vorgelegter Ausdruck unverändert aus dem Tool
|
||||||
|
* stammt: gleiche Eingaben ⇒ gleiche Prüfsumme. Hochgeladene Scans
|
||||||
|
* werden ebenfalls gehasht und im Register verankert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Berechnet den SHA-256-Hash eines Textes als Hex-String. */
|
||||||
|
export async function sha256Hex(text: string): Promise<string> {
|
||||||
|
const data = new TextEncoder().encode(text);
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Berechnet den SHA-256-Hash von Binärdaten (z. B. Scan-Datei) als Hex-String. */
|
||||||
|
export async function sha256HexBytes(data: ArrayBuffer): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFY_TEXT: Readonly<Record<'de' | 'en', { label: string; note: string }>> = {
|
||||||
|
de: {
|
||||||
|
label: 'Dokument-Prüfsumme (SHA-256)',
|
||||||
|
note: 'Verifikation: Die Prüfsumme wurde über den vorstehenden Dokumentinhalt gebildet. Ein vorgelegter Ausdruck ist unverändert, wenn die im Tool neu erzeugte Prüfsumme übereinstimmt.',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
label: 'Document checksum (SHA-256)',
|
||||||
|
note: 'Verification: the checksum was computed over the document content above. A presented printout is unaltered if the checksum re-generated in the tool matches.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hängt eine Verifikations-Fußzeile (Prüfsumme über den Inhalt) an ein
|
||||||
|
* generiertes Markdown-Dokument an.
|
||||||
|
*
|
||||||
|
* @param markdown - Fertiges Dokument
|
||||||
|
* @param locale - Dokumentsprache (Fußzeile DE/EN)
|
||||||
|
* @returns Dokument mit Prüfsummen-Fußzeile
|
||||||
|
*/
|
||||||
|
export async function withVerification(markdown: string, locale: Locale): Promise<string> {
|
||||||
|
const t = locale === 'de' ? VERIFY_TEXT.de : VERIFY_TEXT.en;
|
||||||
|
const hash = await sha256Hex(markdown);
|
||||||
|
return `${markdown}\n\n**${t.label}:** \`${hash}\`\n\n> ${t.note}`;
|
||||||
|
}
|
||||||
@ -66,6 +66,19 @@ export const CS: UIStrings = {
|
|||||||
quizPrintTest: 'Papírová verze testu',
|
quizPrintTest: 'Papírová verze testu',
|
||||||
quizPrintKey: 'List s řešením',
|
quizPrintKey: 'List s řešením',
|
||||||
quizPrintHandout: 'Školicí materiál (12 pravidel)',
|
quizPrintHandout: 'Školicí materiál (12 pravidel)',
|
||||||
|
quizManualTitle: 'Přidat účastníka ručně (papírový test)',
|
||||||
|
quizManualHint: 'Pro testy na papíře: zadejte účastníka a výsledek, poté přiložte naskenovaný vyhodnocený arch — důkazní řetězec je tak úplný.',
|
||||||
|
quizManualScore: 'Správné odpovědi (z 12)',
|
||||||
|
quizManualAdd: 'Přidat do registru',
|
||||||
|
quizSourceApp: 'Aplikace',
|
||||||
|
quizSourcePaper: 'Papír',
|
||||||
|
quizColSource: 'Zdroj',
|
||||||
|
quizColEvidence: 'Doklad',
|
||||||
|
quizAttach: 'Přiložit sken',
|
||||||
|
quizAttachView: 'Otevřít sken',
|
||||||
|
quizAttachTooBig: 'Soubor je příliš velký — použijte sken do 1 MB (např. komprimovaný JPG/PDF).',
|
||||||
|
quizAttachDone: 'Sken uložen a ukotven v registru kontrolním součtem SHA-256.',
|
||||||
|
quizDeleteRecord: 'Opravdu smazat záznam „{name}"? Přiložené doklady budou ztraceny.',
|
||||||
|
|
||||||
wizardTitleNew: 'Posoudit nový systém AI',
|
wizardTitleNew: 'Posoudit nový systém AI',
|
||||||
wizardTitleRe: 'Nové posouzení: {name}',
|
wizardTitleRe: 'Nové posouzení: {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const ES: UIStrings = {
|
|||||||
quizPrintTest: 'Versión en papel del test',
|
quizPrintTest: 'Versión en papel del test',
|
||||||
quizPrintKey: 'Hoja de soluciones',
|
quizPrintKey: 'Hoja de soluciones',
|
||||||
quizPrintHandout: 'Material de formación (12 reglas)',
|
quizPrintHandout: 'Material de formación (12 reglas)',
|
||||||
|
quizManualTitle: 'Registrar participante manualmente (test en papel)',
|
||||||
|
quizManualHint: 'Para tests realizados en papel: introduzca participante y resultado, y adjunte el cuestionario corregido escaneado — así la cadena de evidencia queda completa.',
|
||||||
|
quizManualScore: 'Respuestas correctas (de 12)',
|
||||||
|
quizManualAdd: 'Añadir al registro',
|
||||||
|
quizSourceApp: 'App',
|
||||||
|
quizSourcePaper: 'Papel',
|
||||||
|
quizColSource: 'Fuente',
|
||||||
|
quizColEvidence: 'Evidencia',
|
||||||
|
quizAttach: 'Adjuntar escaneo',
|
||||||
|
quizAttachView: 'Abrir escaneo',
|
||||||
|
quizAttachTooBig: 'Archivo demasiado grande — use un escaneo de menos de 1 MB (p. ej. JPG/PDF comprimido).',
|
||||||
|
quizAttachDone: 'Escaneo guardado y anclado en el registro con suma de verificación SHA-256.',
|
||||||
|
quizDeleteRecord: '¿Eliminar realmente la entrada «{name}»? Las evidencias adjuntas se perderán.',
|
||||||
|
|
||||||
wizardTitleNew: 'Evaluar un nuevo sistema de IA',
|
wizardTitleNew: 'Evaluar un nuevo sistema de IA',
|
||||||
wizardTitleRe: 'Reevaluación: {name}',
|
wizardTitleRe: 'Reevaluación: {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const FR: UIStrings = {
|
|||||||
quizPrintTest: 'Version papier du test',
|
quizPrintTest: 'Version papier du test',
|
||||||
quizPrintKey: 'Corrigé',
|
quizPrintKey: 'Corrigé',
|
||||||
quizPrintHandout: 'Support de formation (12 règles)',
|
quizPrintHandout: 'Support de formation (12 règles)',
|
||||||
|
quizManualTitle: 'Saisir un participant manuellement (test papier)',
|
||||||
|
quizManualHint: 'Pour les tests réalisés sur papier : saisissez le participant et le résultat, puis joignez le questionnaire corrigé numérisé — la chaîne de preuve est ainsi complète.',
|
||||||
|
quizManualScore: 'Réponses correctes (sur 12)',
|
||||||
|
quizManualAdd: 'Ajouter au registre',
|
||||||
|
quizSourceApp: 'App',
|
||||||
|
quizSourcePaper: 'Papier',
|
||||||
|
quizColSource: 'Source',
|
||||||
|
quizColEvidence: 'Preuve',
|
||||||
|
quizAttach: 'Joindre un scan',
|
||||||
|
quizAttachView: 'Ouvrir le scan',
|
||||||
|
quizAttachTooBig: 'Fichier trop volumineux — utilisez un scan de moins de 1 Mo (p. ex. JPG/PDF compressé).',
|
||||||
|
quizAttachDone: 'Scan enregistré et ancré dans le registre avec une somme de contrôle SHA-256.',
|
||||||
|
quizDeleteRecord: 'Supprimer vraiment l’entrée « {name} » ? Les preuves jointes seront perdues.',
|
||||||
|
|
||||||
wizardTitleNew: 'Évaluer un nouveau système d’IA',
|
wizardTitleNew: 'Évaluer un nouveau système d’IA',
|
||||||
wizardTitleRe: 'Réévaluation : {name}',
|
wizardTitleRe: 'Réévaluation : {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const IT: UIStrings = {
|
|||||||
quizPrintTest: 'Versione cartacea del test',
|
quizPrintTest: 'Versione cartacea del test',
|
||||||
quizPrintKey: 'Foglio delle soluzioni',
|
quizPrintKey: 'Foglio delle soluzioni',
|
||||||
quizPrintHandout: 'Dispensa formativa (12 regole)',
|
quizPrintHandout: 'Dispensa formativa (12 regole)',
|
||||||
|
quizManualTitle: 'Registrare manualmente un partecipante (test cartaceo)',
|
||||||
|
quizManualHint: 'Per i test svolti su carta: inserite partecipante e risultato, poi allegate il foglio corretto scansionato — la catena probatoria è così completa.',
|
||||||
|
quizManualScore: 'Risposte corrette (su 12)',
|
||||||
|
quizManualAdd: 'Aggiungi al registro',
|
||||||
|
quizSourceApp: 'App',
|
||||||
|
quizSourcePaper: 'Carta',
|
||||||
|
quizColSource: 'Fonte',
|
||||||
|
quizColEvidence: 'Prova',
|
||||||
|
quizAttach: 'Allega scansione',
|
||||||
|
quizAttachView: 'Apri scansione',
|
||||||
|
quizAttachTooBig: 'File troppo grande — usate una scansione sotto 1 MB (p. es. JPG/PDF compresso).',
|
||||||
|
quizAttachDone: 'Scansione salvata e ancorata nel registro con checksum SHA-256.',
|
||||||
|
quizDeleteRecord: 'Eliminare davvero la voce «{name}»? Le prove allegate andranno perse.',
|
||||||
|
|
||||||
wizardTitleNew: 'Valutare un nuovo sistema di IA',
|
wizardTitleNew: 'Valutare un nuovo sistema di IA',
|
||||||
wizardTitleRe: 'Rivalutazione: {name}',
|
wizardTitleRe: 'Rivalutazione: {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const PL: UIStrings = {
|
|||||||
quizPrintTest: 'Wersja papierowa testu',
|
quizPrintTest: 'Wersja papierowa testu',
|
||||||
quizPrintKey: 'Arkusz odpowiedzi',
|
quizPrintKey: 'Arkusz odpowiedzi',
|
||||||
quizPrintHandout: 'Materiał szkoleniowy (12 zasad)',
|
quizPrintHandout: 'Materiał szkoleniowy (12 zasad)',
|
||||||
|
quizManualTitle: 'Dodaj uczestnika ręcznie (test papierowy)',
|
||||||
|
quizManualHint: 'Dla testów na papierze: wpisz uczestnika i wynik, a następnie załącz zeskanowany, oceniony arkusz — łańcuch dowodowy będzie kompletny.',
|
||||||
|
quizManualScore: 'Poprawne odpowiedzi (z 12)',
|
||||||
|
quizManualAdd: 'Dodaj do rejestru',
|
||||||
|
quizSourceApp: 'Aplikacja',
|
||||||
|
quizSourcePaper: 'Papier',
|
||||||
|
quizColSource: 'Źródło',
|
||||||
|
quizColEvidence: 'Dowód',
|
||||||
|
quizAttach: 'Załącz skan',
|
||||||
|
quizAttachView: 'Otwórz skan',
|
||||||
|
quizAttachTooBig: 'Plik za duży — użyj skanu poniżej 1 MB (np. skompresowany JPG/PDF).',
|
||||||
|
quizAttachDone: 'Skan zapisany i powiązany z rejestrem sumą kontrolną SHA-256.',
|
||||||
|
quizDeleteRecord: 'Czy na pewno usunąć wpis „{name}"? Załączone dowody zostaną utracone.',
|
||||||
|
|
||||||
wizardTitleNew: 'Oceń nowy system AI',
|
wizardTitleNew: 'Oceń nowy system AI',
|
||||||
wizardTitleRe: 'Ponowna ocena: {name}',
|
wizardTitleRe: 'Ponowna ocena: {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const RO: UIStrings = {
|
|||||||
quizPrintTest: 'Versiunea pe hârtie a testului',
|
quizPrintTest: 'Versiunea pe hârtie a testului',
|
||||||
quizPrintKey: 'Foaia de răspunsuri',
|
quizPrintKey: 'Foaia de răspunsuri',
|
||||||
quizPrintHandout: 'Material de formare (12 reguli)',
|
quizPrintHandout: 'Material de formare (12 reguli)',
|
||||||
|
quizManualTitle: 'Adaugă participant manual (test pe hârtie)',
|
||||||
|
quizManualHint: 'Pentru testele pe hârtie: introduceți participantul și rezultatul, apoi atașați foaia corectată scanată — lanțul probatoriu este astfel complet.',
|
||||||
|
quizManualScore: 'Răspunsuri corecte (din 12)',
|
||||||
|
quizManualAdd: 'Adaugă în registru',
|
||||||
|
quizSourceApp: 'Aplicație',
|
||||||
|
quizSourcePaper: 'Hârtie',
|
||||||
|
quizColSource: 'Sursă',
|
||||||
|
quizColEvidence: 'Dovadă',
|
||||||
|
quizAttach: 'Atașează scanarea',
|
||||||
|
quizAttachView: 'Deschide scanarea',
|
||||||
|
quizAttachTooBig: 'Fișier prea mare — folosiți o scanare sub 1 MB (de ex. JPG/PDF comprimat).',
|
||||||
|
quizAttachDone: 'Scanare salvată și ancorată în registru cu sumă de control SHA-256.',
|
||||||
|
quizDeleteRecord: 'Chiar ștergeți intrarea „{name}"? Dovezile atașate se vor pierde.',
|
||||||
|
|
||||||
wizardTitleNew: 'Evaluează un sistem de IA nou',
|
wizardTitleNew: 'Evaluează un sistem de IA nou',
|
||||||
wizardTitleRe: 'Reevaluare: {name}',
|
wizardTitleRe: 'Reevaluare: {name}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ export const SK: UIStrings = {
|
|||||||
quizPrintTest: 'Papierová verzia testu',
|
quizPrintTest: 'Papierová verzia testu',
|
||||||
quizPrintKey: 'Hárok s riešeniami',
|
quizPrintKey: 'Hárok s riešeniami',
|
||||||
quizPrintHandout: 'Školiaci materiál (12 pravidiel)',
|
quizPrintHandout: 'Školiaci materiál (12 pravidiel)',
|
||||||
|
quizManualTitle: 'Pridať účastníka ručne (papierový test)',
|
||||||
|
quizManualHint: 'Pre testy na papieri: zadajte účastníka a výsledok, potom priložte naskenovaný vyhodnotený hárok — dôkazová reťaz je tak úplná.',
|
||||||
|
quizManualScore: 'Správne odpovede (z 12)',
|
||||||
|
quizManualAdd: 'Pridať do registra',
|
||||||
|
quizSourceApp: 'Aplikácia',
|
||||||
|
quizSourcePaper: 'Papier',
|
||||||
|
quizColSource: 'Zdroj',
|
||||||
|
quizColEvidence: 'Doklad',
|
||||||
|
quizAttach: 'Priložiť sken',
|
||||||
|
quizAttachView: 'Otvoriť sken',
|
||||||
|
quizAttachTooBig: 'Súbor je príliš veľký — použite sken do 1 MB (napr. komprimovaný JPG/PDF).',
|
||||||
|
quizAttachDone: 'Sken uložený a ukotvený v registri kontrolným súčtom SHA-256.',
|
||||||
|
quizDeleteRecord: 'Naozaj vymazať záznam „{name}"? Priložené doklady sa stratia.',
|
||||||
|
|
||||||
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}',
|
||||||
|
|||||||
@ -66,6 +66,19 @@ const DE = {
|
|||||||
quizPrintTest: 'Papierfassung des Tests',
|
quizPrintTest: 'Papierfassung des Tests',
|
||||||
quizPrintKey: 'Lösungsbogen',
|
quizPrintKey: 'Lösungsbogen',
|
||||||
quizPrintHandout: 'Schulungshandout (12 Regeln)',
|
quizPrintHandout: 'Schulungshandout (12 Regeln)',
|
||||||
|
quizManualTitle: 'Teilnehmer manuell erfassen (Papier-Test)',
|
||||||
|
quizManualHint: 'Für auf Papier durchgeführte Tests: Teilnehmer/in und Ergebnis eintragen, danach den ausgewerteten Bogen als Scan anhängen — so ist die Nachweiskette vollständig.',
|
||||||
|
quizManualScore: 'Richtige Antworten (von 12)',
|
||||||
|
quizManualAdd: 'In Register aufnehmen',
|
||||||
|
quizSourceApp: 'App',
|
||||||
|
quizSourcePaper: 'Papier',
|
||||||
|
quizColSource: 'Quelle',
|
||||||
|
quizColEvidence: 'Beleg',
|
||||||
|
quizAttach: 'Scan anhängen',
|
||||||
|
quizAttachView: 'Scan öffnen',
|
||||||
|
quizAttachTooBig: 'Datei zu groß — bitte Scan unter 1 MB verwenden (z. B. als komprimiertes JPG/PDF).',
|
||||||
|
quizAttachDone: 'Scan gespeichert und mit SHA-256-Prüfsumme im Register verankert.',
|
||||||
|
quizDeleteRecord: 'Eintrag „{name}" wirklich löschen? Belege gehen mit verloren.',
|
||||||
|
|
||||||
wizardTitleNew: 'Neues KI-System bewerten',
|
wizardTitleNew: 'Neues KI-System bewerten',
|
||||||
wizardTitleRe: 'Neubewertung: {name}',
|
wizardTitleRe: 'Neubewertung: {name}',
|
||||||
@ -264,6 +277,19 @@ const EN: UIStrings = {
|
|||||||
quizPrintTest: 'Paper version of the test',
|
quizPrintTest: 'Paper version of the test',
|
||||||
quizPrintKey: 'Answer key',
|
quizPrintKey: 'Answer key',
|
||||||
quizPrintHandout: 'Training handout (12 rules)',
|
quizPrintHandout: 'Training handout (12 rules)',
|
||||||
|
quizManualTitle: 'Add participant manually (paper test)',
|
||||||
|
quizManualHint: 'For tests taken on paper: enter participant and score, then attach the marked sheet as a scan — completing the evidence chain.',
|
||||||
|
quizManualScore: 'Correct answers (of 12)',
|
||||||
|
quizManualAdd: 'Add to register',
|
||||||
|
quizSourceApp: 'App',
|
||||||
|
quizSourcePaper: 'Paper',
|
||||||
|
quizColSource: 'Source',
|
||||||
|
quizColEvidence: 'Evidence',
|
||||||
|
quizAttach: 'Attach scan',
|
||||||
|
quizAttachView: 'Open scan',
|
||||||
|
quizAttachTooBig: 'File too large — please use a scan under 1 MB (e.g. compressed JPG/PDF).',
|
||||||
|
quizAttachDone: 'Scan stored and anchored in the register with a SHA-256 checksum.',
|
||||||
|
quizDeleteRecord: 'Really delete entry “{name}”? Attached evidence will be lost.',
|
||||||
|
|
||||||
wizardTitleNew: 'Assess a new AI system',
|
wizardTitleNew: 'Assess a new AI system',
|
||||||
wizardTitleRe: 'Reassessment: {name}',
|
wizardTitleRe: 'Reassessment: {name}',
|
||||||
|
|||||||
@ -167,6 +167,10 @@ export function App(): JSX.Element {
|
|||||||
profile={profile}
|
profile={profile}
|
||||||
records={training}
|
records={training}
|
||||||
onRecord={(record) => setTraining((prev) => [record, ...prev])}
|
onRecord={(record) => setTraining((prev) => [record, ...prev])}
|
||||||
|
onUpdateRecord={(record) =>
|
||||||
|
setTraining((prev) => prev.map((r) => (r.id === record.id ? record : r)))
|
||||||
|
}
|
||||||
|
onDeleteRecord={(id) => setTraining((prev) => prev.filter((r) => r.id !== id))}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
answerKeyDoc,
|
answerKeyDoc,
|
||||||
certificateDoc,
|
certificateDoc,
|
||||||
@ -9,8 +9,9 @@ import {
|
|||||||
trainingLogDoc,
|
trainingLogDoc,
|
||||||
} from '../engine/quiz';
|
} from '../engine/quiz';
|
||||||
import type { QuizScore, TrainingRecord } from '../engine/quiz';
|
import type { QuizScore, TrainingRecord } from '../engine/quiz';
|
||||||
import { QUIZ_PASS_PCT } from '../engine/quizContent';
|
import { QUIZ_PASS_PCT, QUIZ_QUESTIONS } from '../engine/quizContent';
|
||||||
import type { CompanyProfile } from '../engine/types';
|
import type { CompanyProfile } from '../engine/types';
|
||||||
|
import { sha256HexBytes, withVerification } from '../engine/verify';
|
||||||
import { useI18n } from '../i18n/context';
|
import { useI18n } from '../i18n/context';
|
||||||
import { fmt } from '../i18n/ui';
|
import { fmt } from '../i18n/ui';
|
||||||
import { printDocument } from '../print/print';
|
import { printDocument } from '../print/print';
|
||||||
@ -20,13 +21,24 @@ interface QuizViewProps {
|
|||||||
readonly profile: CompanyProfile;
|
readonly profile: CompanyProfile;
|
||||||
readonly records: readonly TrainingRecord[];
|
readonly records: readonly TrainingRecord[];
|
||||||
readonly onRecord: (record: TrainingRecord) => void;
|
readonly onRecord: (record: TrainingRecord) => void;
|
||||||
|
readonly onUpdateRecord: (record: TrainingRecord) => void;
|
||||||
|
readonly onDeleteRecord: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_SCAN_BYTES = 1_000_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KI-Kompetenztest (Art. 4): einfacher Grundlagentest mit Auswertung,
|
* KI-Kompetenztest (Art. 4): Test mit Auswertung, druckbaren
|
||||||
* druckbarem Zertifikat und lokalem Schulungsregister als Nachweis.
|
* Schulungsunterlagen, manueller Teilnehmer-Erfassung (Papier-Tests),
|
||||||
|
* Scan-Belegen mit SHA-256-Prüfsumme und Schulungsregister.
|
||||||
*/
|
*/
|
||||||
export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Element {
|
export function QuizView({
|
||||||
|
profile,
|
||||||
|
records,
|
||||||
|
onRecord,
|
||||||
|
onUpdateRecord,
|
||||||
|
onDeleteRecord,
|
||||||
|
}: QuizViewProps): JSX.Element {
|
||||||
const { t, locale, pack } = useI18n();
|
const { t, locale, pack } = useI18n();
|
||||||
const today = todayISO();
|
const today = todayISO();
|
||||||
const questions = useMemo(() => getQuiz(locale), [locale]);
|
const questions = useMemo(() => getQuiz(locale), [locale]);
|
||||||
@ -36,24 +48,86 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
questions.map(() => null),
|
questions.map(() => null),
|
||||||
);
|
);
|
||||||
const [result, setResult] = useState<QuizScore | null>(null);
|
const [result, setResult] = useState<QuizScore | null>(null);
|
||||||
|
const [lastRecordId, setLastRecordId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [manualName, setManualName] = useState('');
|
||||||
|
const [manualCorrect, setManualCorrect] = useState('');
|
||||||
|
const [attachTarget, setAttachTarget] = useState<string | null>(null);
|
||||||
|
const [attachMessage, setAttachMessage] = useState('');
|
||||||
|
const scanInput = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const complete = name.trim() !== '' && answers.every((a) => a !== null);
|
const complete = name.trim() !== '' && answers.every((a) => a !== null);
|
||||||
|
|
||||||
|
const printVerified = (file: string, markdown: string): void => {
|
||||||
|
void withVerification(markdown, locale).then((verified) =>
|
||||||
|
printDocument(file, verified, profile),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
const score = scoreQuiz(answers);
|
const score = scoreQuiz(answers);
|
||||||
|
const id = crypto.randomUUID();
|
||||||
setResult(score);
|
setResult(score);
|
||||||
|
setLastRecordId(id);
|
||||||
onRecord({
|
onRecord({
|
||||||
id: crypto.randomUUID(),
|
id,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
date: today,
|
date: today,
|
||||||
scorePct: score.pct,
|
scorePct: score.pct,
|
||||||
passed: score.passed,
|
passed: score.passed,
|
||||||
|
source: 'app',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const reset = (): void => {
|
const addManual = (): void => {
|
||||||
setAnswers(questions.map(() => null));
|
const correct = Number(manualCorrect);
|
||||||
setResult(null);
|
if (manualName.trim() === '' || !Number.isInteger(correct) || correct < 0 || correct > QUIZ_QUESTIONS.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pct = Math.round((correct / QUIZ_QUESTIONS.length) * 100);
|
||||||
|
onRecord({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: manualName.trim(),
|
||||||
|
date: today,
|
||||||
|
scorePct: pct,
|
||||||
|
passed: pct >= QUIZ_PASS_PCT,
|
||||||
|
source: 'paper',
|
||||||
|
});
|
||||||
|
setManualName('');
|
||||||
|
setManualCorrect('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachScan = async (file: File): Promise<void> => {
|
||||||
|
const record = records.find((r) => r.id === attachTarget);
|
||||||
|
if (!record) return;
|
||||||
|
if (file.size > MAX_SCAN_BYTES) {
|
||||||
|
setAttachMessage(t.quizAttachTooBig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const sha256 = await sha256HexBytes(buffer);
|
||||||
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
|
||||||
|
reader.onerror = () => reject(new Error('read failed'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
onUpdateRecord({ ...record, attachment: { fileName: file.name, sha256, dataUrl } });
|
||||||
|
setAttachMessage(t.quizAttachDone);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openScan = (record: TrainingRecord): void => {
|
||||||
|
if (!record.attachment) return;
|
||||||
|
const win = window.open('', '_blank');
|
||||||
|
if (!win) return;
|
||||||
|
if (record.attachment.dataUrl.startsWith('data:application/pdf')) {
|
||||||
|
win.document.write(
|
||||||
|
`<iframe src="${record.attachment.dataUrl}" style="border:0;width:100vw;height:100vh"></iframe>`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
win.document.write(`<img src="${record.attachment.dataUrl}" style="max-width:100%">`);
|
||||||
|
}
|
||||||
|
win.document.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -86,9 +160,7 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
type="radio"
|
type="radio"
|
||||||
name={q.id}
|
name={q.id}
|
||||||
checked={answers[qi] === oi}
|
checked={answers[qi] === oi}
|
||||||
onChange={() =>
|
onChange={() => setAnswers(answers.map((a, i) => (i === qi ? oi : a)))}
|
||||||
setAnswers(answers.map((a, i) => (i === qi ? oi : a)))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span>{option}</span>
|
<span>{option}</span>
|
||||||
</label>
|
</label>
|
||||||
@ -122,16 +194,22 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
<button
|
<button
|
||||||
className="primary"
|
className="primary"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
printDocument(
|
printVerified(
|
||||||
`ki-kompetenz-${name.trim()}-${today}`,
|
`ki-kompetenz-${name.trim()}-${today}`,
|
||||||
certificateDoc(name.trim(), result, pack, today, profile, locale),
|
certificateDoc(name.trim(), result, pack, today, profile, locale, lastRecordId ?? undefined),
|
||||||
profile,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t.quizCert}
|
{t.quizCert}
|
||||||
</button>
|
</button>
|
||||||
<button className="ghost" onClick={reset}>
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setAnswers(questions.map(() => null));
|
||||||
|
setResult(null);
|
||||||
|
setLastRecordId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t.quizRetry}
|
{t.quizRetry}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -180,6 +258,35 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<h2 style={{ marginTop: 0 }}>{t.quizManualTitle}</h2>
|
||||||
|
<p className="hint">{t.quizManualHint}</p>
|
||||||
|
<div className="profile-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.quizName}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={manualName}
|
||||||
|
placeholder={t.quizNamePh}
|
||||||
|
onChange={(e) => setManualName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t.quizManualScore}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={manualCorrect}
|
||||||
|
placeholder={`0–${QUIZ_QUESTIONS.length}`}
|
||||||
|
onChange={(e) => setManualCorrect(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button className="primary" onClick={addManual}>
|
||||||
|
{t.quizManualAdd}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<div className="panel-head">
|
<div className="panel-head">
|
||||||
<h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2>
|
<h2 style={{ margin: 0 }}>{t.quizLogTitle}</h2>
|
||||||
@ -187,10 +294,9 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
<button
|
<button
|
||||||
className="ghost"
|
className="ghost"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
printDocument(
|
printVerified(
|
||||||
`schulungsregister-${today}`,
|
`schulungsregister-${today}`,
|
||||||
trainingLogDoc(records, pack, today, profile, locale),
|
trainingLogDoc(records, pack, today, profile, locale),
|
||||||
profile,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -199,6 +305,18 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="hint">{t.quizLogHint}</p>
|
<p className="hint">{t.quizLogHint}</p>
|
||||||
|
{attachMessage && <p className="hint">{attachMessage}</p>}
|
||||||
|
<input
|
||||||
|
ref={scanInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*,application/pdf"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) void attachScan(file);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{records.length === 0 ? (
|
{records.length === 0 ? (
|
||||||
<p className="hint">{t.quizLogEmpty}</p>
|
<p className="hint">{t.quizLogEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
@ -208,8 +326,10 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{t.colUpdated}</th>
|
<th>{t.colUpdated}</th>
|
||||||
<th>{t.colOwner}</th>
|
<th>{t.colOwner}</th>
|
||||||
|
<th>{t.quizColSource}</th>
|
||||||
<th>{t.quizColScore}</th>
|
<th>{t.quizColScore}</th>
|
||||||
<th>{t.quizColStatus}</th>
|
<th>{t.quizColStatus}</th>
|
||||||
|
<th>{t.quizColEvidence}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -217,12 +337,46 @@ export function QuizView({ profile, records, onRecord }: QuizViewProps): JSX.Ele
|
|||||||
<tr key={r.id}>
|
<tr key={r.id}>
|
||||||
<td>{uiDate(r.date, locale)}</td>
|
<td>{uiDate(r.date, locale)}</td>
|
||||||
<td>{r.name}</td>
|
<td>{r.name}</td>
|
||||||
|
<td>{(r.source ?? 'app') === 'paper' ? t.quizSourcePaper : t.quizSourceApp}</td>
|
||||||
<td>{r.scorePct} %</td>
|
<td>{r.scorePct} %</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${r.passed ? 'minimal' : 'high'}`}>
|
<span className={`badge ${r.passed ? 'minimal' : 'high'}`}>
|
||||||
{r.passed ? t.quizPassed : t.quizFailed}
|
{r.passed ? t.quizPassed : t.quizFailed}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="toolbar" style={{ margin: 0 }}>
|
||||||
|
{r.attachment ? (
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
title={`SHA-256: ${r.attachment.sha256}`}
|
||||||
|
onClick={() => openScan(r)}
|
||||||
|
>
|
||||||
|
✓ {t.quizAttachView}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setAttachTarget(r.id);
|
||||||
|
scanInput.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.quizAttach}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="danger"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(fmt(t.quizDeleteRecord, { name: r.name }))) {
|
||||||
|
onDeleteRecord(r.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user