From 4f6bc8f6f6b8283424f7c7d4ce4bf5baf7b0bd61 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 16 Mar 2026 21:41:48 +0100 Subject: [PATCH] feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 --- .../app/api/sdk/v1/canonical/route.ts | 6 +- .../api/sdk/v1/training/[[...path]]/route.ts | 26 +- .../control-library/components/helpers.tsx | 48 +- .../app/sdk/control-library/page.tsx | 94 +- .../app/sdk/training/learner/page.tsx | 560 +++ admin-compliance/app/sdk/training/page.tsx | 339 +- .../components/sdk/Sidebar/SDKSidebar.tsx | 26 + .../training/InteractiveVideoPlayer.tsx | 321 ++ admin-compliance/lib/sdk/training/api.ts | 155 + admin-compliance/lib/sdk/training/types.ts | 130 +- ai-compliance-sdk/cmd/server/main.go | 32 +- .../data/ce-libraries/evidence-library-50.md | 270 ++ .../data/ce-libraries/hazard-library-150.md | 3531 +++++++++++++++++ .../lifecycle-phases-library-25.md | 145 + .../machine-components-library.md | 155 + .../protection-measures-library-200.md | 246 ++ .../data/ce-libraries/roles-library-20.md | 322 ++ .../internal/api/handlers/rag_handlers.go | 1 - .../api/handlers/training_handlers.go | 753 +++- .../api/handlers/training_handlers_test.go | 691 ++++ .../internal/training/block_generator.go | 282 ++ .../internal/training/block_generator_test.go | 224 ++ .../internal/training/block_store.go | 484 +++ .../internal/training/content_generator.go | 376 ++ .../training/content_generator_test.go | 552 +++ .../internal/training/escalation_test.go | 159 + .../training/interactive_video_test.go | 801 ++++ ai-compliance-sdk/internal/training/media.go | 168 +- ai-compliance-sdk/internal/training/models.go | 267 +- ai-compliance-sdk/internal/training/store.go | 284 ++ .../migrations/021_training_blocks.sql | 47 + .../migrations/022_interactive_training.sql | 37 + .../api/canonical_control_routes.py | 13 +- .../api/control_generator_routes.py | 192 + .../compliance/api/extraction_routes.py | 1 - .../compliance/services/citation_backfill.py | 437 ++ .../compliance/services/control_generator.py | 324 +- .../059_wiki_cra_annex_i_detail.sql | 292 ++ .../tests/test_citation_backfill.py | 221 ++ compliance-tts-service/main.py | 128 + compliance-tts-service/slide_renderer.py | 94 + compliance-tts-service/tts_engine.py | 8 +- compliance-tts-service/video_generator.py | 133 + developer-portal/app/api/training/page.tsx | 439 ++ .../components/DevPortalLayout.tsx | 1 + docs-src/control_generator.py | 1991 ++++++++++ docs-src/control_generator_routes.py | 976 +++++ .../sdk-modules/canonical-control-library.md | 155 +- docs-src/services/sdk-modules/training.md | 202 +- scripts/cleanup-qdrant-duplicates.py | 358 ++ 50 files changed, 17299 insertions(+), 198 deletions(-) create mode 100644 admin-compliance/app/sdk/training/learner/page.tsx create mode 100644 admin-compliance/components/training/InteractiveVideoPlayer.tsx create mode 100644 ai-compliance-sdk/data/ce-libraries/evidence-library-50.md create mode 100644 ai-compliance-sdk/data/ce-libraries/hazard-library-150.md create mode 100644 ai-compliance-sdk/data/ce-libraries/lifecycle-phases-library-25.md create mode 100644 ai-compliance-sdk/data/ce-libraries/machine-components-library.md create mode 100644 ai-compliance-sdk/data/ce-libraries/protection-measures-library-200.md create mode 100644 ai-compliance-sdk/data/ce-libraries/roles-library-20.md create mode 100644 ai-compliance-sdk/internal/api/handlers/training_handlers_test.go create mode 100644 ai-compliance-sdk/internal/training/block_generator.go create mode 100644 ai-compliance-sdk/internal/training/block_generator_test.go create mode 100644 ai-compliance-sdk/internal/training/block_store.go create mode 100644 ai-compliance-sdk/internal/training/content_generator_test.go create mode 100644 ai-compliance-sdk/internal/training/escalation_test.go create mode 100644 ai-compliance-sdk/internal/training/interactive_video_test.go create mode 100644 ai-compliance-sdk/migrations/021_training_blocks.sql create mode 100644 ai-compliance-sdk/migrations/022_interactive_training.sql create mode 100644 backend-compliance/compliance/services/citation_backfill.py create mode 100644 backend-compliance/migrations/059_wiki_cra_annex_i_detail.sql create mode 100644 backend-compliance/tests/test_citation_backfill.py create mode 100644 developer-portal/app/api/training/page.tsx create mode 100644 docs-src/control_generator.py create mode 100644 docs-src/control_generator_routes.py create mode 100644 scripts/cleanup-qdrant-duplicates.py diff --git a/admin-compliance/app/api/sdk/v1/canonical/route.ts b/admin-compliance/app/api/sdk/v1/canonical/route.ts index ebaffaa..ab998b1 100644 --- a/admin-compliance/app/api/sdk/v1/canonical/route.ts +++ b/admin-compliance/app/api/sdk/v1/canonical/route.ts @@ -26,7 +26,7 @@ export async function GET(request: NextRequest) { case 'controls': { const controlParams = new URLSearchParams() - const passthrough = ['severity', 'domain', 'verification_method', 'category', + const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'target_audience', 'source', 'search', 'sort', 'order', 'limit', 'offset'] for (const key of passthrough) { const val = searchParams.get(key) @@ -39,7 +39,7 @@ export async function GET(request: NextRequest) { case 'controls-count': { const countParams = new URLSearchParams() - const countPassthrough = ['severity', 'domain', 'verification_method', 'category', + const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'target_audience', 'source', 'search'] for (const key of countPassthrough) { const val = searchParams.get(key) @@ -175,6 +175,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Missing control id' }, { status: 400 }) } backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}` + } else if (endpoint === 'bulk-review') { + backendPath = '/api/compliance/v1/canonical/generate/bulk-review' } else if (endpoint === 'blocked-sources-cleanup') { backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup' } else if (endpoint === 'similarity-check') { diff --git a/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts b/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts index f448906..83111d8 100644 --- a/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts +++ b/admin-compliance/app/api/sdk/v1/training/[[...path]]/route.ts @@ -53,7 +53,18 @@ async function proxyRequest( } } - const response = await fetch(url, fetchOptions) + const response = await fetch(url, { + ...fetchOptions, + redirect: 'manual', + }) + + // Handle redirects (e.g. media stream presigned URL) + if (response.status === 307 || response.status === 302) { + const location = response.headers.get('location') + if (location) { + return NextResponse.redirect(location) + } + } if (!response.ok) { const errorText = await response.text() @@ -69,6 +80,19 @@ async function proxyRequest( ) } + // Handle binary responses (PDF, octet-stream) + const contentType = response.headers.get('content-type') || '' + if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) { + const buffer = await response.arrayBuffer() + return new NextResponse(buffer, { + status: response.status, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': response.headers.get('content-disposition') || '', + }, + }) + } + const data = await response.json() return NextResponse.json(data) } catch (error) { diff --git a/admin-compliance/app/sdk/control-library/components/helpers.tsx b/admin-compliance/app/sdk/control-library/components/helpers.tsx index 50e168f..146f7ac 100644 --- a/admin-compliance/app/sdk/control-library/components/helpers.tsx +++ b/admin-compliance/app/sdk/control-library/components/helpers.tsx @@ -44,7 +44,7 @@ export interface CanonicalControl { customer_visible?: boolean verification_method: string | null category: string | null - target_audience: string | null + target_audience: string | string[] | null generation_metadata?: Record | null generation_strategy?: string | null created_at: string @@ -142,10 +142,27 @@ export const CATEGORY_OPTIONS = [ ] export const TARGET_AUDIENCE_OPTIONS: Record = { + // Legacy English keys enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' }, authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' }, provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' }, all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' }, + // German keys from LLM generation + unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' }, + behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' }, + entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' }, + datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' }, + geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' }, + 'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' }, + rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' }, + 'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' }, + personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' }, + einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' }, + produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' }, + vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' }, + gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' }, + finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' }, + oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' }, } export const COLLECTION_OPTIONS = [ @@ -223,11 +240,32 @@ export function CategoryBadge({ category }: { category: string | null }) { ) } -export function TargetAudienceBadge({ audience }: { audience: string | null }) { +export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) { if (!audience) return null - const config = TARGET_AUDIENCE_OPTIONS[audience] - if (!config) return null - return {config.label} + + // Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]') + let items: string[] = [] + if (typeof audience === 'string') { + if (audience.startsWith('[')) { + try { items = JSON.parse(audience) } catch { items = [audience] } + } else { + items = [audience] + } + } else if (Array.isArray(audience)) { + items = audience + } + + if (items.length === 0) return null + + return ( + + {items.map((item, i) => { + const config = TARGET_AUDIENCE_OPTIONS[item] + if (!config) return {item} + return {config.label} + })} + + ) } export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) { diff --git a/admin-compliance/app/sdk/control-library/page.tsx b/admin-compliance/app/sdk/control-library/page.tsx index 9158d67..4cbaedd 100644 --- a/admin-compliance/app/sdk/control-library/page.tsx +++ b/admin-compliance/app/sdk/control-library/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { Shield, Search, ChevronRight, ChevronLeft, Filter, Lock, - BookOpen, Plus, Zap, BarChart3, ListChecks, + BookOpen, Plus, Zap, BarChart3, ListChecks, Trash2, ChevronsLeft, ChevronsRight, ArrowUpDown, Clock, RefreshCw, } from 'lucide-react' import { @@ -263,6 +263,33 @@ export default function ControlLibraryPage() { } catch { /* ignore */ } } + const [bulkProcessing, setBulkProcessing] = useState(false) + + const handleBulkReject = async (sourceState: string) => { + const count = stateFilter === sourceState ? totalCount : reviewCount + if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return + setBulkProcessing(true) + try { + const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ release_state: sourceState, action: 'reject' }), + }) + if (res.ok) { + const data = await res.json() + alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`) + await fullReload() + } else { + const err = await res.json() + alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) + } + } catch { + alert('Netzwerkfehler') + } finally { + setBulkProcessing(false) + } + } + const loadProcessedStats = async () => { try { const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`) @@ -381,13 +408,23 @@ export default function ControlLibraryPage() {
{reviewCount > 0 && ( - + <> + + + )}
+ + ))} + +
+ +
+ + )} + + )} + + {/* Tab: Zertifikate */} + {activeTab === 'certificates' && ( +
+ {certificates.length === 0 ? ( +
+ Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab. +
+ ) : ( +
+ {certificates.map(cert => ( +
+
+

{cert.module_title}

+ Bestanden +
+
+

Mitarbeiter: {cert.user_name}

+

Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}

+ {cert.quiz_score != null &&

Ergebnis: {Math.round(cert.quiz_score)}%

} +

ID: {cert.certificate_id?.substring(0, 12)}

+
+ {cert.certificate_id && ( + + )} +
+ ))} +
+ )} +
+ )} + + ) +} diff --git a/admin-compliance/app/sdk/training/page.tsx b/admin-compliance/app/sdk/training/page.tsx index 4ff3c6a..ae003e6 100644 --- a/admin-compliance/app/sdk/training/page.tsx +++ b/admin-compliance/app/sdk/training/page.tsx @@ -9,14 +9,18 @@ import { createModule, updateModule, deleteModule, deleteMatrixEntry, setMatrixEntry, startAssignment, completeAssignment, updateAssignment, + listBlockConfigs, createBlockConfig, deleteBlockConfig, + previewBlock, generateBlock, getCanonicalMeta, + generateInteractiveVideo, } from '@/lib/sdk/training/api' import type { TrainingModule, TrainingAssignment, MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia, + TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult, } from '@/lib/sdk/training/types' import { REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS, - STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, + STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS, } from '@/lib/sdk/training/types' import AudioPlayer from '@/components/training/AudioPlayer' import VideoPlayer from '@/components/training/VideoPlayer' @@ -41,6 +45,7 @@ export default function TrainingPage() { const [bulkGenerating, setBulkGenerating] = useState(false) const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null) const [moduleMedia, setModuleMedia] = useState([]) + const [interactiveGenerating, setInteractiveGenerating] = useState(false) const [statusFilter, setStatusFilter] = useState('') const [regulationFilter, setRegulationFilter] = useState('') @@ -52,6 +57,15 @@ export default function TrainingPage() { const [selectedAssignment, setSelectedAssignment] = useState(null) const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null) + // Block (Controls → Module) state + const [blocks, setBlocks] = useState([]) + const [canonicalMeta, setCanonicalMeta] = useState(null) + const [showBlockCreate, setShowBlockCreate] = useState(false) + const [blockPreview, setBlockPreview] = useState(null) + const [blockPreviewId, setBlockPreviewId] = useState('') + const [blockGenerating, setBlockGenerating] = useState(false) + const [blockResult, setBlockResult] = useState(null) + useEffect(() => { loadData() }, []) @@ -66,13 +80,15 @@ export default function TrainingPage() { setLoading(true) setError(null) try { - const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([ + const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([ getStats(), getModules(), getMatrix(), getAssignments({ limit: 50 }), getDeadlines(10), getAuditLog({ limit: 30 }), + listBlockConfigs(), + getCanonicalMeta(), ]) if (statsRes.status === 'fulfilled') setStats(statsRes.value) @@ -81,6 +97,8 @@ export default function TrainingPage() { if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments) if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines) if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries) + if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks) + if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value) } catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden') } finally { @@ -114,6 +132,19 @@ export default function TrainingPage() { } } + async function handleGenerateInteractiveVideo() { + if (!selectedModuleId) return + setInteractiveGenerating(true) + try { + await generateInteractiveVideo(selectedModuleId) + await loadModuleMedia(selectedModuleId) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung') + } finally { + setInteractiveGenerating(false) + } + } + async function handlePublishContent(contentId: string) { try { await publishContent(contentId) @@ -190,6 +221,59 @@ export default function TrainingPage() { } } + // Block handlers + async function handleCreateBlock(data: { + name: string; description?: string; domain_filter?: string; category_filter?: string; + severity_filter?: string; target_audience_filter?: string; regulation_area: string; + module_code_prefix: string; max_controls_per_module?: number; + }) { + try { + await createBlockConfig(data) + setShowBlockCreate(false) + const res = await listBlockConfigs() + setBlocks(res.blocks) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Erstellen') + } + } + + async function handleDeleteBlock(id: string) { + if (!confirm('Block-Konfiguration wirklich loeschen?')) return + try { + await deleteBlockConfig(id) + const res = await listBlockConfigs() + setBlocks(res.blocks) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Loeschen') + } + } + + async function handlePreviewBlock(id: string) { + setBlockPreviewId(id) + setBlockPreview(null) + setBlockResult(null) + try { + const preview = await previewBlock(id) + setBlockPreview(preview) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Preview') + } + } + + async function handleGenerateBlock(id: string) { + setBlockGenerating(true) + setBlockResult(null) + try { + const result = await generateBlock(id, { language: 'de', auto_matrix: true }) + setBlockResult(result) + await loadData() + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung') + } finally { + setBlockGenerating(false) + } + } + const tabs: { id: Tab; label: string }[] = [ { id: 'overview', label: 'Uebersicht' }, { id: 'modules', label: 'Modulkatalog' }, @@ -521,6 +605,228 @@ export default function TrainingPage() { {activeTab === 'content' && (
+ + {/* Training Blocks — Controls → Schulungsmodule */} +
+
+
+

Schulungsbloecke aus Controls

+

+ Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren + {canonicalMeta && ({canonicalMeta.total} Controls verfuegbar)} +

+
+ +
+ + {/* Block list */} + {blocks.length > 0 ? ( +
+ + + + + + + + + + + + + + {blocks.map(block => ( + + + + + + + + + + ))} + +
NameDomainZielgruppeSeverityPrefixLetzte GenerierungAktionen
+
{block.name}
+ {block.description &&
{block.description}
} +
{block.domain_filter || 'Alle'}{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}{block.severity_filter || 'Alle'}{block.module_code_prefix}{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'} +
+ + + +
+
+
+ ) : ( +
+ Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln. +
+ )} + + {/* Preview result */} + {blockPreview && blockPreviewId && ( +
+

Preview: {blocks.find(b => b.id === blockPreviewId)?.name}

+
+ Controls: {blockPreview.control_count} + Module: {blockPreview.module_count} + Rollen: {blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')} +
+ {blockPreview.controls.length > 0 && ( +
+ Passende Controls anzeigen ({blockPreview.control_count}) +
+ {blockPreview.controls.slice(0, 50).map(ctrl => ( +
+ {ctrl.control_id} + {ctrl.title} + {ctrl.severity} +
+ ))} + {blockPreview.control_count > 50 &&
... und {blockPreview.control_count - 50} weitere
} +
+
+ )} +
+ )} + + {/* Generate result */} + {blockResult && ( +
+

Generierung abgeschlossen

+
+ Module erstellt: {blockResult.modules_created} + Controls verknuepft: {blockResult.controls_linked} + Matrix-Eintraege: {blockResult.matrix_entries_created} + Content generiert: {blockResult.content_generated} +
+ {blockResult.errors && blockResult.errors.length > 0 && ( +
+ {blockResult.errors.map((err, i) =>
{err}
)} +
+ )} +
+ )} +
+ + {/* Block Create Modal */} + {showBlockCreate && ( +
+
+

Neuen Schulungsblock erstellen

+
{ + e.preventDefault() + const fd = new FormData(e.currentTarget) + handleCreateBlock({ + name: fd.get('name') as string, + description: fd.get('description') as string || undefined, + domain_filter: fd.get('domain_filter') as string || undefined, + category_filter: fd.get('category_filter') as string || undefined, + severity_filter: fd.get('severity_filter') as string || undefined, + target_audience_filter: fd.get('target_audience_filter') as string || undefined, + regulation_area: fd.get('regulation_area') as string, + module_code_prefix: fd.get('module_code_prefix') as string, + max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20, + }) + }} className="space-y-3"> +
+ + +
+
+ +