1cc0c3d34a
- 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>
252 lines
8.5 KiB
TypeScript
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)
|
|
}
|