From 8682522212240aaf261ca78f41237511d08cb2ca Mon Sep 17 00:00:00 2001
From: Benjamin Admin
Date: Sat, 9 May 2026 10:47:01 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20Variantenmanagement=20=E2=80=94=20Sub-P?=
=?UTF-8?q?rojekte=20mit=20GAP-Analyse?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend:
- parent_project_id auf iace_projects (DB + Go Struct)
- POST/GET /variants + GET /variant-gap Endpoints
- GAP-Analyse: Differenz Hazards/Massnahmen/Kategorien
Frontend:
- VariantPanel auf Projekt-Uebersicht
- Variante erstellen Dialog
- Sidebar-Anzeige (Variantenanzahl / Basis-Link)
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../[projectId]/_components/VariantPanel.tsx | 253 ++++++++++++++++++
.../app/sdk/iace/[projectId]/page.tsx | 24 ++
admin-compliance/app/sdk/iace/layout.tsx | 45 +++-
.../api/handlers/iace_handler_projects.go | 85 ++++++
ai-compliance-sdk/internal/app/routes.go | 3 +
ai-compliance-sdk/internal/iace/models_api.go | 23 ++
.../internal/iace/models_entities.go | 1 +
.../internal/iace/store_projects.go | 169 +++++++++++-
8 files changed, 592 insertions(+), 11 deletions(-)
create mode 100644 admin-compliance/app/sdk/iace/[projectId]/_components/VariantPanel.tsx
diff --git a/admin-compliance/app/sdk/iace/[projectId]/_components/VariantPanel.tsx b/admin-compliance/app/sdk/iace/[projectId]/_components/VariantPanel.tsx
new file mode 100644
index 0000000..6db00e1
--- /dev/null
+++ b/admin-compliance/app/sdk/iace/[projectId]/_components/VariantPanel.tsx
@@ -0,0 +1,253 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import Link from 'next/link'
+
+interface VariantProject {
+ id: string
+ machine_name: string
+ description?: string
+ status: string
+ hazard_count?: number
+ parent_project_id?: string
+}
+
+interface VariantGapResponse {
+ base_project: { id: string; name: string; hazard_count: number; measure_count: number }
+ variant: { id: string; name: string; hazard_count: number; measure_count: number }
+ gap: { additional_hazards: number; additional_measures: number; categories_affected: string[] }
+}
+
+interface Props {
+ projectId: string
+ parentProjectId?: string | null
+ parentProjectName?: string
+}
+
+export function VariantPanel({ projectId, parentProjectId, parentProjectName }: Props) {
+ const [variants, setVariants] = useState([])
+ const [gapMap, setGapMap] = useState>({})
+ const [loading, setLoading] = useState(true)
+ const [showCreate, setShowCreate] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [name, setName] = useState('')
+ const [description, setDescription] = useState('')
+
+ const fetchVariants = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`)
+ if (!res.ok) {
+ setVariants([])
+ return
+ }
+ const json = await res.json()
+ const list: VariantProject[] = json.variants || json.projects || []
+ setVariants(list)
+
+ // Fetch gap analysis for this project
+ const gapRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variant-gap`)
+ if (gapRes.ok) {
+ const gapJson = await gapRes.json()
+ const gaps: Record = {}
+ // Could be a single gap or array — handle both
+ if (Array.isArray(gapJson)) {
+ for (const g of gapJson) {
+ gaps[g.variant?.id] = g
+ }
+ } else if (gapJson.variant) {
+ gaps[gapJson.variant.id] = gapJson
+ }
+ setGapMap(gaps)
+ }
+ } catch {
+ setVariants([])
+ } finally {
+ setLoading(false)
+ }
+ }, [projectId])
+
+ useEffect(() => {
+ fetchVariants()
+ }, [fetchVariants])
+
+ async function handleCreate() {
+ if (!name.trim()) return
+ setCreating(true)
+ try {
+ const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ machine_name: name.trim(),
+ description: description.trim(),
+ }),
+ })
+ if (res.ok) {
+ setName('')
+ setDescription('')
+ setShowCreate(false)
+ fetchVariants()
+ }
+ } catch {
+ // silently handle
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ // If this project IS a variant, show link to base project
+ if (parentProjectId) {
+ return (
+
+
+
+
+
Variante
+
+ Dieses Projekt ist eine Variante des Basis-Projekts
+
+
+
+ {parentProjectName || 'Basis-Projekt'}
+
+
+
+
+ )
+ }
+
+ if (loading) return null
+ if (variants.length === 0 && !showCreate) {
+ return (
+
+
+
+
+
+
Keine Varianten
+
Erstellen Sie Varianten fuer verschiedene Betriebsarten
+
+
+
+
+ {renderCreateDialog()}
+
+ )
+ }
+
+ function renderCreateDialog() {
+ if (!showCreate) return null
+ return (
+
+
Neue Variante erstellen
+
setName(e.target.value)}
+ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
+ />
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ Varianten ({variants.length})
+
+
Betriebsart-spezifische Projektversionen
+
+
+
+
+
+
+ {variants.map((v) => {
+ const gap = gapMap[v.id]
+ return (
+
+
+
+
+ {v.machine_name}
+
+ {v.description && (
+
{v.description}
+ )}
+
+
+
+ {gap && gap.gap.additional_hazards > 0 && (
+
+ +{gap.gap.additional_hazards} Gefaehrdungen
+
+ )}
+
+ )
+ })}
+
+
+ {renderCreateDialog()}
+
+ )
+}
diff --git a/admin-compliance/app/sdk/iace/[projectId]/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/page.tsx
index 1cdc51f..ad83eae 100644
--- a/admin-compliance/app/sdk/iace/[projectId]/page.tsx
+++ b/admin-compliance/app/sdk/iace/[projectId]/page.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useParams } from 'next/navigation'
import { SuggestedNorms } from './_components/SuggestedNorms'
import { ComplianceAlerts } from './_components/ComplianceAlerts'
+import { VariantPanel } from './_components/VariantPanel'
interface ProjectOverview {
id: string
@@ -15,6 +16,8 @@ interface ProjectOverview {
completeness_pct: number
created_at: string
updated_at: string
+ parent_project_id?: string | null
+ parent_project_name?: string
metadata?: { limits_form?: Record }
risk_summary?: {
critical?: number
@@ -125,12 +128,26 @@ export default function ProjectOverviewPage() {
const stepsComplete = [hasLimits, hasComponents, hasHazards, hasMitigations].filter(Boolean).length
const completeness = Math.round((stepsComplete / 6) * 100)
+ // If this is a variant, resolve parent project name
+ let parentName: string | undefined
+ if (json.parent_project_id) {
+ try {
+ const parentRes = await fetch(`/api/sdk/v1/iace/projects/${json.parent_project_id}`)
+ if (parentRes.ok) {
+ const parentJson = await parentRes.json()
+ parentName = parentJson.machine_name
+ }
+ } catch { /* ignore */ }
+ }
+
setProject({
...json,
completeness_pct: completeness,
component_count: compCount,
hazard_count: hazCount,
mitigation_count: mitCount,
+ parent_project_id: json.parent_project_id || null,
+ parent_project_name: parentName,
metadata: json.metadata,
risk_summary: {
critical: rs.critical || 0,
@@ -334,6 +351,13 @@ export default function ProjectOverviewPage() {
{/* Compliance Alerts */}
+ {/* Variant Management */}
+
+
{/* Suggested Norms */}
diff --git a/admin-compliance/app/sdk/iace/layout.tsx b/admin-compliance/app/sdk/iace/layout.tsx
index 0adc87a..c53ff8e 100644
--- a/admin-compliance/app/sdk/iace/layout.tsx
+++ b/admin-compliance/app/sdk/iace/layout.tsx
@@ -108,12 +108,40 @@ export default function IACELayout({ children }: { children: React.ReactNode })
const params = useParams()
const projectId = params?.projectId as string | undefined
const [projectName, setProjectName] = React.useState('')
+ const [variantInfo, setVariantInfo] = React.useState<{
+ parentProjectId?: string; parentName?: string; variantCount?: number
+ }>({})
React.useEffect(() => {
if (!projectId) return
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
.then(r => r.ok ? r.json() : null)
- .then(d => { if (d?.machine_name) setProjectName(d.machine_name) })
+ .then(async (d) => {
+ if (!d?.machine_name) return
+ setProjectName(d.machine_name)
+ // Resolve variant info
+ if (d.parent_project_id) {
+ try {
+ const pRes = await fetch(`/api/sdk/v1/iace/projects/${d.parent_project_id}`)
+ if (pRes.ok) {
+ const pj = await pRes.json()
+ setVariantInfo({ parentProjectId: d.parent_project_id, parentName: pj.machine_name })
+ } else {
+ setVariantInfo({ parentProjectId: d.parent_project_id })
+ }
+ } catch { setVariantInfo({ parentProjectId: d.parent_project_id }) }
+ } else {
+ // Check if this project has variants
+ try {
+ const vRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`)
+ if (vRes.ok) {
+ const vj = await vRes.json()
+ const list = vj.variants || vj.projects || []
+ if (list.length > 0) setVariantInfo({ variantCount: list.length })
+ }
+ } catch { /* no variants endpoint yet */ }
+ }
+ })
.catch(() => {})
}, [projectId])
@@ -148,6 +176,21 @@ export default function IACELayout({ children }: { children: React.ReactNode })
{projectName}
)}
+ {variantInfo.parentProjectId && (
+
+
+ Variante von: {variantInfo.parentName || 'Basis'}
+
+ )}
+ {variantInfo.variantCount != null && variantInfo.variantCount > 0 && (
+ ({variantInfo.variantCount} Varianten)
+ )}
CE-Compliance