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>
This commit is contained in:
Benjamin Admin
2026-05-07 15:44:05 +02:00
parent 6e71996733
commit 1cc0c3d34a
5 changed files with 766 additions and 260 deletions
@@ -0,0 +1,243 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useParams } from 'next/navigation'
interface OrderData {
client: {
company: string; contact: string; email: string; phone: string; address: string
}
order: {
number: string; received_date: string; description: string; scope: string[]
}
offer: {
date: string; number: string; amount: string; status: string; accepted_date: string
}
notes: string
}
const EMPTY: OrderData = {
client: { company: '', contact: '', email: '', phone: '', address: '' },
order: { number: '', received_date: '', description: '', scope: [] },
offer: { date: '', number: '', amount: '', status: 'offen', accepted_date: '' },
notes: '',
}
const SCOPE_OPTIONS = [
'Risikobeurteilung', 'Normenrecherche', 'Betriebsanleitung', 'CE-Kennzeichnung', 'Schulung',
]
const STATUS_OPTIONS = [
{ value: 'offen', label: 'Offen' },
{ value: 'angenommen', label: 'Angenommen' },
{ value: 'abgelehnt', label: 'Abgelehnt' },
{ value: 'storniert', label: 'Storniert' },
]
const STATUS_COLORS: Record<string, string> = {
offen: 'bg-gray-100 text-gray-700',
angenommen: 'bg-green-100 text-green-700',
abgelehnt: 'bg-red-100 text-red-700',
storniert: 'bg-gray-100 text-gray-500',
}
export default function OrderPage() {
const params = useParams()
const projectId = params.projectId as string
const [data, setData] = useState<OrderData>(EMPTY)
const [loading, setLoading] = useState(true)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const existingMetaRef = useRef<Record<string, unknown>>({})
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
.then((r) => r.ok ? r.json() : null)
.then((json) => {
if (!json) return
const proj = json.project || json
const meta = proj.metadata || {}
existingMetaRef.current = meta
if (meta.order_data) setData({ ...EMPTY, ...meta.order_data })
})
.catch(() => {})
.finally(() => setLoading(false))
}, [projectId])
const save = useCallback(async (next: OrderData) => {
setSaveState('saving')
try {
const merged = { ...existingMetaRef.current, order_data: next }
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: merged }),
})
existingMetaRef.current = merged
setSaveState('saved')
setTimeout(() => setSaveState('idle'), 2000)
} catch {
setSaveState('idle')
}
}, [projectId])
const update = useCallback((next: OrderData) => {
setData(next)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => save(next), 800)
}, [save])
const setClient = (k: keyof OrderData['client'], v: string) =>
update({ ...data, client: { ...data.client, [k]: v } })
const setOrder = (k: keyof OrderData['order'], v: string) =>
update({ ...data, order: { ...data.order, [k]: v } })
const setOffer = (k: keyof OrderData['offer'], v: string) =>
update({ ...data, offer: { ...data.offer, [k]: v } })
const toggleScope = (s: string) => {
const cur = data.order.scope
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
update({ ...data, order: { ...data.order, scope: next } })
}
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 max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Auftrag</h1>
<p className="mt-1 text-sm text-gray-500">Auftraggeber und Auftragsdaten erfassen</p>
</div>
<span className={`text-xs px-2.5 py-1 rounded-full font-medium transition-opacity ${
saveState === 'saving' ? 'bg-yellow-100 text-yellow-700'
: saveState === 'saved' ? 'bg-green-100 text-green-700'
: 'opacity-0'
}`}>
{saveState === 'saving' ? 'Speichert...' : 'Gespeichert'}
</span>
</div>
{/* Auftraggeber */}
<Card title="Auftraggeber">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Firmenname" value={data.client.company} onChange={(v) => setClient('company', v)} />
<Input label="Ansprechpartner" value={data.client.contact} onChange={(v) => setClient('contact', v)} />
<Input label="E-Mail" type="email" value={data.client.email} onChange={(v) => setClient('email', v)} />
<Input label="Telefon" type="tel" value={data.client.phone} onChange={(v) => setClient('phone', v)} />
</div>
<div className="mt-4">
<Textarea label="Adresse" value={data.client.address} onChange={(v) => setClient('address', v)} rows={2} />
</div>
</Card>
{/* Auftrag */}
<Card title="Auftrag">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Auftragsnummer" value={data.order.number} onChange={(v) => setOrder('number', v)} />
<Input label="Eingangsdatum" type="date" value={data.order.received_date} onChange={(v) => setOrder('received_date', v)} />
</div>
<div className="mt-4">
<Textarea label="Beschreibung des Auftrags" value={data.order.description} onChange={(v) => setOrder('description', v)}
placeholder="z.B. CE-Risikobeurteilung fuer Cobot-Zelle" rows={2} />
</div>
<div className="mt-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Umfang</label>
<div className="flex flex-wrap gap-2">
{SCOPE_OPTIONS.map((s) => {
const active = data.order.scope.includes(s)
return (
<button key={s} type="button" onClick={() => toggleScope(s)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
active ? 'bg-purple-100 border-purple-300 text-purple-700 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'
}`}>{active ? '\u2713 ' : ''}{s}</button>
)
})}
</div>
</div>
</Card>
{/* Angebot */}
<Card title="Angebot">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Angebotsdatum" type="date" value={data.offer.date} onChange={(v) => setOffer('date', v)} />
<Input label="Angebotsnummer" value={data.offer.number} onChange={(v) => setOffer('number', v)} />
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Angebotssumme</label>
<div className="relative">
<input type="number" min="0" step="0.01" value={data.offer.amount}
onChange={(e) => setOffer('amount', e.target.value)}
className="w-full pr-8 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 pointer-events-none">&euro;</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<div className="flex items-center gap-3">
<select value={data.offer.status} onChange={(e) => setOffer('status', e.target.value)}
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{STATUS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<span className={`px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${STATUS_COLORS[data.offer.status] || ''}`}>
{STATUS_OPTIONS.find((o) => o.value === data.offer.status)?.label}
</span>
</div>
</div>
</div>
{data.offer.status === 'angenommen' && (
<div className="mt-4">
<Input label="Annahmedatum" type="date" value={data.offer.accepted_date} onChange={(v) => setOffer('accepted_date', v)} />
</div>
)}
</Card>
{/* Notizen */}
<Card title="Notizen">
<Textarea value={data.notes} onChange={(v) => update({ ...data, notes: v })}
placeholder="Freitext-Notizen zum Auftrag..." rows={4} />
</Card>
</div>
)
}
/* --- Shared form primitives --- */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{title}</h2>
{children}
</div>
)
}
function Input({ label, value, onChange, type = 'text', placeholder }: {
label?: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string
}) {
return (
<div>
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
<input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)
}
function Textarea({ label, value, onChange, placeholder, rows = 3 }: {
label?: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number
}) {
return (
<div>
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
<textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none" />
</div>
)
}