feat(iace): Klaerungen Phase 3 — DB-Tabelle + Multi-User + PDF-Export

[migration-approved]

Three pieces complete the Klaerungen lifecycle:

1. Migration 028: iace_clarifications + iace_clarification_comments +
   iace_clarification_history. Deterministic clarification_key
   (UNIQUE per project) so engine re-inits don't lose answers.
   History table logs every status/answer transition. The previous
   JSONB-in-metadata storage is kept as read-only fallback for
   pre-migration projects until a one-shot upcopy script runs.

2. Multi-User-Workflow:
   - assigned_to field on every clarification (free-text user kuerzel
     for now; an FK to users can be added in a follow-up).
   - Comment thread per clarification (POST .../comment, GET
     .../detail returns the thread).
   - Status-history log written by UpsertClarification when the
     status or answer actually changes.
   - Frontend Modal: Zugewiesen-an + Bearbeiter fields, comment
     thread with inline post, collapsible history section.

3. PDF-Export via print-friendly HTML:
   - GET /clarifications.html returns a standalone A4-styled
     document with status badges, norm references, affected hazards
     and a signature row at the bottom. The Bediener opens the link
     and uses Strg-P / Cmd-P to save as PDF. No server-side PDF
     dependency added.
   - Frontend "PDF / Druck" button next to CSV export.

Backend:
- internal/iace/store_clarifications.go: UpsertClarification,
  ListClarificationsForProject, GetClarificationByKey,
  AddClarificationComment, ListClarificationComments,
  ListClarificationHistory.
- internal/api/handlers/iace_handler_clarifications.go:
  - AnswerClarification now writes the SQL row, falls back to legacy
    JSONB read on list.
  - PostClarificationComment, ListClarificationDetail,
    ExportClarificationsHTML added.

Migration must be applied manually on Mac Mini and prod via
psql -f /migrations/028_iace_clarifications.sql — pattern as in
scripts/apply_*_migration.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-17 01:39:17 +02:00
parent b2b4d77877
commit c4be077c5d
6 changed files with 778 additions and 29 deletions
@@ -16,6 +16,7 @@ type Clarification = {
reasoning?: string
answered_by?: string
answered_at?: string
assigned_to?: string
}
type ListResponse = {
@@ -117,7 +118,19 @@ export default function ClarificationsPage() {
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV-Export
CSV
</a>
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.html`}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="Druckansicht öffnen — mit Strg/Cmd-P als PDF speichern"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF / Druck
</a>
</div>
</div>
@@ -220,13 +233,23 @@ function Badge({ color, label }: { color: string; label: string }) {
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
}
type Comment = { id: string; author: string; body: string; created_at: string }
type HistoryEntry = {
actor: string
from_status?: string
to_status?: string
from_answer?: string
to_answer?: string
created_at: string
}
function AnswerModal({
clarification,
projectId,
onClose,
onSaved,
}: {
clarification: Clarification
clarification: Clarification & { assigned_to?: string }
projectId: string
onClose: () => void
onSaved: () => void
@@ -237,9 +260,26 @@ function AnswerModal({
)
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [history, setHistory] = useState<HistoryEntry[]>([])
const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false)
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return
setComments(d.comments || [])
setHistory(d.history || [])
})
.catch(() => {})
}, [projectId, clarification.id])
const save = async () => {
setSaving(true)
setError(null)
@@ -249,7 +289,15 @@ function AnswerModal({
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, answer, reasoning, answered_by: answeredBy }),
body: JSON.stringify({
status, answer, reasoning,
answered_by: answeredBy,
assigned_to: assignedTo,
question: clarification.question,
source: clarification.source,
category: clarification.category,
norm_references: clarification.norm_references,
}),
}
)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
@@ -261,12 +309,59 @@ function AnswerModal({
}
}
const postComment = async () => {
if (!newComment.trim()) return
setPostingComment(true)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }),
}
)
if (r.ok) {
const d = await r.json()
if (d.comment) setComments(prev => [...prev, d.comment])
setNewComment('')
} else {
setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`)
}
} finally {
setPostingComment(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={onClose}>
<div className="bg-white rounded-lg max-w-xl w-full p-5 shadow-xl" onClick={e => e.stopPropagation()}>
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
<div className="bg-white rounded-lg max-w-2xl w-full p-5 shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
<div className="text-base font-medium mb-4">{clarification.question}</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Zugewiesen an</label>
<input
type="text"
value={assignedTo}
onChange={e => setAssignedTo(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="z.B. anlagenbauer@fanuc.de"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="Name oder Kürzel"
/>
</div>
</div>
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
<div className="flex gap-1 mb-3 text-sm">
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
@@ -302,18 +397,53 @@ function AnswerModal({
value={reasoning}
onChange={e => setReasoning(e.target.value)}
rows={4}
className="w-full border rounded p-2 text-sm mb-3"
className="w-full border rounded p-2 text-sm mb-4"
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
/>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm mb-4"
placeholder="Name oder Kürzel"
/>
{/* Comment Thread */}
<div className="border-t pt-3 mt-3 mb-3">
<div className="text-xs font-medium text-gray-700 mb-2">Diskussion ({comments.length})</div>
<div className="space-y-2 max-h-40 overflow-y-auto mb-2">
{comments.map(c => (
<div key={c.id} className="text-xs bg-gray-50 rounded p-2">
<div className="font-medium text-gray-700">{c.author || 'anonym'} <span className="text-gray-400 font-normal">· {c.created_at.slice(0, 16).replace('T', ' ')}</span></div>
<div className="text-gray-700 whitespace-pre-wrap">{c.body}</div>
</div>
))}
{comments.length === 0 && <div className="text-xs text-gray-400 italic">Noch keine Kommentare.</div>}
</div>
<div className="flex gap-1">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
placeholder="Kommentar hinzufügen..."
className="flex-1 border rounded px-2 py-1.5 text-xs"
onKeyDown={e => { if (e.key === 'Enter') postComment() }}
/>
<button
onClick={postComment}
disabled={postingComment || !newComment.trim()}
className="px-3 py-1 rounded bg-gray-700 text-white text-xs hover:bg-gray-800 disabled:opacity-50"
>Senden</button>
</div>
</div>
{history.length > 0 && (
<details className="mb-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">Verlauf ({history.length})</summary>
<div className="mt-1 space-y-1 text-gray-600">
{history.map((h, i) => (
<div key={i} className="border-l-2 border-gray-200 pl-2">
<span className="text-gray-400">{h.created_at.slice(0, 16).replace('T', ' ')}</span> ·
<strong> {h.actor || 'unbekannt'}</strong>: {h.from_status} {h.to_status}
{h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'}${h.to_answer || '—'})`}
</div>
))}
</div>
</details>
)}
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}