Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx
T
Benjamin Admin 1cc0c3d34a feat: Auftrag-Tab + Grenzen-Formular + CE-Report-Export
- Auftrag-Tab: Kunde, Anfrage, Angebot mit Status-Tracking
- Grenzen & Verwendung: 6 Sektionen (Produktbeschreibung, Verwendung,
  Fehlanwendung, Grenzen, Schnittstellen, Betroffene Personen)
- CE-Akte Export: PDF (window.print) + Excel (CSV) mit allen Sektionen
  (Normen, Gefaehrdungen, Risikobewertung, Massnahmen, Compliance)
- Navigation: Auftrag als 2. Tab, Briefcase-Icon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 15:44:05 +02:00

252 lines
8.5 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { LimitsFormSections } from './_components/LimitsFormSections'
import { EMPTY_LIMITS_FORM, type LimitsFormData } from './_types'
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
interface ProjectData {
machine_name: string
machine_type: string
manufacturer: string
metadata?: {
limits_form?: LimitsFormData
[key: string]: unknown
}
}
export default function IACEInterviewPage() {
const { projectId } = useParams<{ projectId: string }>()
const router = useRouter()
const [formData, setFormData] = useState<LimitsFormData>(EMPTY_LIMITS_FORM)
const [projectData, setProjectData] = useState<ProjectData | null>(null)
const [loading, setLoading] = useState(true)
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const latestFormRef = useRef<LimitsFormData>(EMPTY_LIMITS_FORM)
// Load project data and existing form data
useEffect(() => {
loadProject()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
async function loadProject() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
if (!res.ok) return
const json = await res.json()
const proj: ProjectData = {
machine_name: json.machine_name || '',
machine_type: json.machine_type || '',
manufacturer: json.manufacturer || '',
metadata: json.metadata || {},
}
setProjectData(proj)
// Restore saved form data from metadata
if (proj.metadata?.limits_form) {
const saved = proj.metadata.limits_form
const merged = { ...EMPTY_LIMITS_FORM, ...saved }
setFormData(merged)
latestFormRef.current = merged
} else {
// Pre-fill from project fields
const prefilled: LimitsFormData = {
...EMPTY_LIMITS_FORM,
machine_designation: proj.machine_name,
machine_type: proj.machine_type,
manufacturer: proj.manufacturer,
}
setFormData(prefilled)
latestFormRef.current = prefilled
}
} catch (err) {
console.error('Failed to load project:', err)
} finally {
setLoading(false)
}
}
// Debounced auto-save
const saveToBackend = useCallback(async (data: LimitsFormData) => {
setSaveStatus('saving')
try {
// Merge limits_form into existing metadata
const existingMetadata = projectData?.metadata || {}
const newMetadata = { ...existingMetadata, limits_form: data }
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: newMetadata }),
})
if (res.ok) {
setSaveStatus('saved')
// Also update local projectData metadata so next save merges correctly
setProjectData((prev) => prev ? { ...prev, metadata: newMetadata } : prev)
setTimeout(() => setSaveStatus((s) => s === 'saved' ? 'idle' : s), 2000)
} else {
setSaveStatus('error')
}
} catch {
setSaveStatus('error')
}
}, [projectId, projectData?.metadata]) // eslint-disable-line react-hooks/exhaustive-deps
const handleFieldChange = useCallback((field: keyof LimitsFormData, value: string | string[]) => {
setFormData((prev) => {
const next = { ...prev, [field]: value }
latestFormRef.current = next
return next
})
// Debounce save: wait 1.5s after last change
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => {
saveToBackend(latestFormRef.current)
}, 1500)
}, [saveToBackend])
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
}
}, [])
// Calculate completion percentage
const completionPct = calculateCompletion(formData)
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Grenzen & Verwendung
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Schritt 3 Bestimmungsgemasse Verwendung, Fehlanwendung und Maschinengrenzen definieren (ISO 12100)
</p>
</div>
<div className="flex items-center gap-3">
<SaveIndicator status={saveStatus} />
<CompletionBadge pct={completionPct} />
</div>
</div>
{/* Form Sections */}
<LimitsFormSections
data={formData}
onChange={handleFieldChange}
prefilled={{
machine_name: projectData?.machine_name,
machine_type: projectData?.machine_type,
manufacturer: projectData?.manufacturer,
}}
/>
{/* Navigation */}
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={() => router.push(`/sdk/iace/${projectId}`)}
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
Zurueck zur Uebersicht
</button>
<button
onClick={() => {
// Flush any pending save
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
saveToBackend(latestFormRef.current)
}
router.push(`/sdk/iace/${projectId}/components`)
}}
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors"
>
Weiter zu Komponenten
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</button>
</div>
</div>
)
}
// ────────────────────────────────────────
// Sub-components (small, page-local)
// ────────────────────────────────────────
function SaveIndicator({ status }: { status: SaveStatus }) {
if (status === 'idle') return null
return (
<div className="flex items-center gap-1.5 text-xs">
{status === 'saving' && (
<>
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
<span className="text-yellow-600 dark:text-yellow-400">Speichert...</span>
</>
)}
{status === 'saved' && (
<>
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-green-600 dark:text-green-400">Gespeichert</span>
</>
)}
{status === 'error' && (
<>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-red-600 dark:text-red-400">Fehler beim Speichern</span>
</>
)}
</div>
)
}
function CompletionBadge({ pct }: { pct: number }) {
const color = pct >= 80 ? 'text-green-600 bg-green-50 border-green-200' :
pct >= 40 ? 'text-yellow-600 bg-yellow-50 border-yellow-200' :
'text-gray-500 bg-gray-50 border-gray-200'
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
{pct}% ausgefuellt
</span>
)
}
/** Calculate how many required-ish fields are filled */
function calculateCompletion(data: LimitsFormData): number {
const checks = [
!!data.machine_designation,
!!data.machine_type,
!!data.manufacturer,
!!data.general_description,
!!data.intended_purpose,
!!data.area_of_use,
data.operating_modes.length > 0,
!!data.foreseeable_misuses,
!!data.spatial_limits,
!!data.operating_conditions,
!!data.energy_supply,
data.person_groups.length > 0,
!!data.qualification_requirements,
]
const filled = checks.filter(Boolean).length
return Math.round((filled / checks.length) * 100)
}