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:
@@ -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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user