c4be077c5d
[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>
567 lines
19 KiB
Go
567 lines
19 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// projectMetadataRoot is the shape we store inside iace_projects.metadata.
|
|
// We only own the "clarification_answers" key; everything else is preserved
|
|
// as opaque JSON so we don't trample on existing fields (limits_form, etc).
|
|
type projectMetadataRoot map[string]json.RawMessage
|
|
|
|
const clarificationAnswersKey = "clarification_answers"
|
|
|
|
// readClarificationAnswers parses project.metadata and returns the
|
|
// clarification_answers map. Missing/empty metadata yields an empty map.
|
|
func readClarificationAnswers(meta json.RawMessage) (map[string]iace.ClarificationAnswer, projectMetadataRoot) {
|
|
root := projectMetadataRoot{}
|
|
if len(meta) > 0 {
|
|
_ = json.Unmarshal(meta, &root)
|
|
}
|
|
answers := map[string]iace.ClarificationAnswer{}
|
|
if raw, ok := root[clarificationAnswersKey]; ok && len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &answers)
|
|
}
|
|
return answers, root
|
|
}
|
|
|
|
// reconstructHazardPatterns re-runs the pattern engine for the project's
|
|
// narrative so we can map each hazard back to the patterns that fired for
|
|
// it. The Hazard table itself doesn't persist the source-pattern list, so
|
|
// this is the only way to know "which clarifications apply to which hazard".
|
|
func (h *IACEHandler) reconstructHazardPatterns(narrative string, machineType string, hazards []iace.Hazard) map[uuid.UUID][]string {
|
|
parsed := iace.ParseNarrative(narrative, machineType)
|
|
compIDs := make([]string, 0, len(parsed.Components))
|
|
for _, c := range parsed.Components {
|
|
compIDs = append(compIDs, c.LibraryID)
|
|
}
|
|
energyIDs := make([]string, 0, len(parsed.EnergySources))
|
|
for _, e := range parsed.EnergySources {
|
|
energyIDs = append(energyIDs, e.SourceID)
|
|
}
|
|
engine := iace.NewPatternEngine()
|
|
out := engine.Match(iace.MatchInput{
|
|
ComponentLibraryIDs: compIDs,
|
|
EnergySourceIDs: energyIDs,
|
|
LifecyclePhases: parsed.LifecyclePhases,
|
|
CustomTags: parsed.CustomTags,
|
|
OperationalStates: parsed.OperationalStates,
|
|
StateTransitions: parsed.StateTransitions,
|
|
HumanRoles: parsed.Roles,
|
|
MachineTypes: []string{machineType},
|
|
})
|
|
|
|
// Map hazard.HazardousZone → set of HP-IDs by substring-matching the
|
|
// pattern's ZoneDE. The hazard table doesn't keep a back-pointer to
|
|
// the source pattern, so this approximation re-runs pattern matching
|
|
// against the narrative and matches by normalised zone.
|
|
hazardToPatterns := map[uuid.UUID][]string{}
|
|
for _, hz := range hazards {
|
|
hzZone := normalizeKey(hz.HazardousZone)
|
|
if hzZone == "" {
|
|
continue
|
|
}
|
|
for _, m := range out.MatchedPatterns {
|
|
pz := normalizeKey(m.ZoneDE)
|
|
if pz == "" {
|
|
continue
|
|
}
|
|
if pz == hzZone || containsSubstring(hzZone, pz) || containsSubstring(pz, hzZone) {
|
|
hazardToPatterns[hz.ID] = appendUnique(hazardToPatterns[hz.ID], m.PatternID)
|
|
}
|
|
}
|
|
}
|
|
return hazardToPatterns
|
|
}
|
|
|
|
func normalizeKey(s string) string {
|
|
s = iace.NormalizeDEPublic(s)
|
|
out := []rune{}
|
|
for _, r := range s {
|
|
switch r {
|
|
case ',', '/', '(', ')', '-', '.', ':', ';':
|
|
out = append(out, ' ')
|
|
default:
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func appendUnique(slice []string, s string) []string {
|
|
for _, x := range slice {
|
|
if x == s {
|
|
return slice
|
|
}
|
|
}
|
|
return append(slice, s)
|
|
}
|
|
|
|
// ListClarifications handles GET /projects/:id/clarifications.
|
|
// Returns the aggregated clarification list with affected-hazard cross-refs
|
|
// and the persisted answer state.
|
|
//
|
|
// Phase 3 storage model: answers live in the iace_clarifications table
|
|
// when migration 028 has been applied. The JSONB fallback in
|
|
// project.metadata.clarification_answers is still read so projects that
|
|
// were answered before the migration keep their state until the one-shot
|
|
// upcopy runs.
|
|
func (h *IACEHandler) ListClarifications(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
project, err := h.store.GetProject(ctx, projectID)
|
|
if err != nil || project == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
|
return
|
|
}
|
|
hazards, _ := h.store.ListHazards(ctx, projectID)
|
|
|
|
// Primary: relational answers
|
|
answers := map[string]iace.ClarificationAnswer{}
|
|
if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil {
|
|
for _, r := range rows {
|
|
answeredAt := ""
|
|
if r.AnsweredAt != nil {
|
|
answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339)
|
|
}
|
|
answers[r.ClarificationKey] = iace.ClarificationAnswer{
|
|
Status: r.Status,
|
|
Answer: r.Answer,
|
|
Reasoning: r.Reasoning,
|
|
AnsweredBy: r.AnsweredBy,
|
|
AnsweredAt: answeredAt,
|
|
AssignedTo: r.AssignedTo,
|
|
}
|
|
}
|
|
}
|
|
// Fallback: JSONB legacy answers (keep until one-shot upcopy is done)
|
|
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
|
|
for k, v := range legacy {
|
|
if _, ok := answers[k]; !ok {
|
|
answers[k] = v
|
|
}
|
|
}
|
|
}
|
|
narrative := extractNarrativeFromMetadata(project.Metadata)
|
|
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
|
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
|
|
|
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
|
sort.Slice(clarifications, func(i, j int) bool {
|
|
// Open first, then answered. Within a status, group by category, then by source.
|
|
if clarifications[i].Status != clarifications[j].Status {
|
|
return clarifications[i].Status == "open"
|
|
}
|
|
if clarifications[i].Category != clarifications[j].Category {
|
|
return clarifications[i].Category < clarifications[j].Category
|
|
}
|
|
return clarifications[i].Source < clarifications[j].Source
|
|
})
|
|
|
|
openCount, answeredCount := 0, 0
|
|
for _, cl := range clarifications {
|
|
switch cl.Status {
|
|
case "answered", "not_relevant":
|
|
answeredCount++
|
|
default:
|
|
openCount++
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"clarifications": clarifications,
|
|
"open_count": openCount,
|
|
"answered_count": answeredCount,
|
|
"total": len(clarifications),
|
|
})
|
|
}
|
|
|
|
// AnswerClarificationRequest is the request body for POST .../answer.
|
|
type AnswerClarificationRequest struct {
|
|
Status string `json:"status"` // open | in_progress | answered | not_relevant
|
|
Answer string `json:"answer"` // ja | nein | teilweise
|
|
Reasoning string `json:"reasoning"`
|
|
AnsweredBy string `json:"answered_by"`
|
|
AssignedTo string `json:"assigned_to"`
|
|
// Snapshot fields written into the new table on first contact so the
|
|
// audit trail does not break if the pattern library changes later.
|
|
Question string `json:"question,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
NormReferences []string `json:"norm_references,omitempty"`
|
|
}
|
|
|
|
// AnswerClarification handles POST /projects/:id/clarifications/:cid/answer.
|
|
// Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers
|
|
// remain readable but are no longer written.
|
|
func (h *IACEHandler) AnswerClarification(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
cid := c.Param("cid")
|
|
if cid == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "missing clarification id"})
|
|
return
|
|
}
|
|
var req AnswerClarificationRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.Status == "" {
|
|
if req.Answer != "" {
|
|
req.Status = "answered"
|
|
} else {
|
|
req.Status = "open"
|
|
}
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
project, err := h.store.GetProject(ctx, projectID)
|
|
if err != nil || project == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
|
return
|
|
}
|
|
tenantID, terr := getTenantID(c)
|
|
if terr != nil {
|
|
tenantID = project.TenantID
|
|
}
|
|
|
|
// If the client didn't supply snapshot fields, fall back to whatever
|
|
// the engine currently produces for this clarification id.
|
|
if req.Question == "" || req.Source == "" {
|
|
if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil {
|
|
if req.Question == "" {
|
|
req.Question = prev.Question
|
|
}
|
|
if req.Source == "" {
|
|
req.Source = prev.Source
|
|
}
|
|
if req.Category == "" {
|
|
req.Category = prev.Category
|
|
}
|
|
if len(req.NormReferences) == 0 {
|
|
req.NormReferences = prev.NormReferences
|
|
}
|
|
}
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
answeredAt := &now
|
|
if req.Status != "answered" && req.Status != "not_relevant" {
|
|
answeredAt = nil
|
|
}
|
|
in := iace.ClarificationRow{
|
|
TenantID: tenantID,
|
|
ProjectID: projectID,
|
|
ClarificationKey: cid,
|
|
Question: req.Question,
|
|
Source: req.Source,
|
|
Category: req.Category,
|
|
NormReferences: req.NormReferences,
|
|
Status: req.Status,
|
|
Answer: req.Answer,
|
|
Reasoning: req.Reasoning,
|
|
AssignedTo: req.AssignedTo,
|
|
AnsweredBy: req.AnsweredBy,
|
|
AnsweredAt: answeredAt,
|
|
}
|
|
row, err := h.store.UpsertClarification(ctx, in)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"clarification_id": cid,
|
|
"row": row,
|
|
})
|
|
}
|
|
|
|
// CommentRequest is the body for POST .../comment.
|
|
type CommentRequest struct {
|
|
Author string `json:"author"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment.
|
|
func (h *IACEHandler) PostClarificationComment(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
cid := c.Param("cid")
|
|
var req CommentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.Body == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "body required"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
row, err := h.store.GetClarificationByKey(ctx, projectID, cid)
|
|
if err != nil || row == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"})
|
|
return
|
|
}
|
|
comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
|
}
|
|
|
|
// ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail
|
|
// and returns comments + history for one clarification.
|
|
func (h *IACEHandler) ListClarificationDetail(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
cid := c.Param("cid")
|
|
ctx := c.Request.Context()
|
|
row, _ := h.store.GetClarificationByKey(ctx, projectID, cid)
|
|
if row == nil {
|
|
c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}})
|
|
return
|
|
}
|
|
comments, _ := h.store.ListClarificationComments(ctx, row.ID)
|
|
history, _ := h.store.ListClarificationHistory(ctx, row.ID)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"row": row,
|
|
"comments": comments,
|
|
"history": history,
|
|
})
|
|
_ = json.RawMessage{} // keep encoding/json import in case of future fields
|
|
}
|
|
|
|
// ExportClarificationsCSV handles GET /projects/:id/clarifications.csv.
|
|
// Returns the aggregated clarifications as a CSV for handover to the
|
|
// Anlagenbauer — one row per question with all referenced hazards and
|
|
// the current answer state.
|
|
func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
project, err := h.store.GetProject(ctx, projectID)
|
|
if err != nil || project == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
|
return
|
|
}
|
|
hazards, _ := h.store.ListHazards(ctx, projectID)
|
|
answers, _ := readClarificationAnswers(project.Metadata)
|
|
narrative := extractNarrativeFromMetadata(project.Metadata)
|
|
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
|
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
|
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
|
|
|
sort.Slice(clarifications, func(i, j int) bool {
|
|
if clarifications[i].Status != clarifications[j].Status {
|
|
return clarifications[i].Status == "open"
|
|
}
|
|
return clarifications[i].Source < clarifications[j].Source
|
|
})
|
|
|
|
filename := fmt.Sprintf("klaerungen_%s_%s.csv", project.MachineName, time.Now().Format("2006-01-02"))
|
|
filename = strings.ReplaceAll(filename, " ", "_")
|
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
|
|
// Excel-Erkennung: UTF-8 BOM voranstellen
|
|
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
|
|
w := csv.NewWriter(c.Writer)
|
|
w.Comma = ';'
|
|
_ = w.Write([]string{
|
|
"ID", "Quelle", "Kategorie", "Frage", "Status", "Antwort", "Begruendung",
|
|
"Bearbeiter", "Beantwortet_am", "Anzahl_Gefaehrdungen", "Gefaehrdungen", "Norm_Referenzen",
|
|
})
|
|
for _, cl := range clarifications {
|
|
_ = w.Write([]string{
|
|
cl.ID,
|
|
cl.Source,
|
|
cl.Category,
|
|
cl.Question,
|
|
cl.Status,
|
|
cl.Answer,
|
|
cl.Reasoning,
|
|
cl.AnsweredBy,
|
|
cl.AnsweredAt,
|
|
fmt.Sprintf("%d", len(cl.AffectedHazardIDs)),
|
|
strings.Join(cl.AffectedHazardNames, " | "),
|
|
strings.Join(cl.NormReferences, " | "),
|
|
})
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// ExportClarificationsHTML handles GET /projects/:id/clarifications.html
|
|
// and returns a print-friendly standalone HTML document that the browser
|
|
// can render to PDF (no server-side PDF dependency needed). The Bediener
|
|
// opens the link, hits Cmd-P / Strg-P and saves as PDF.
|
|
func (h *IACEHandler) ExportClarificationsHTML(c *gin.Context) {
|
|
projectID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
project, err := h.store.GetProject(ctx, projectID)
|
|
if err != nil || project == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
|
return
|
|
}
|
|
hazards, _ := h.store.ListHazards(ctx, projectID)
|
|
answers := map[string]iace.ClarificationAnswer{}
|
|
if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil {
|
|
for _, r := range rows {
|
|
at := ""
|
|
if r.AnsweredAt != nil {
|
|
at = r.AnsweredAt.UTC().Format(time.RFC3339)
|
|
}
|
|
answers[r.ClarificationKey] = iace.ClarificationAnswer{
|
|
Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning,
|
|
AnsweredBy: r.AnsweredBy, AnsweredAt: at,
|
|
}
|
|
}
|
|
}
|
|
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
|
|
for k, v := range legacy {
|
|
if _, ok := answers[k]; !ok {
|
|
answers[k] = v
|
|
}
|
|
}
|
|
}
|
|
narrative := extractNarrativeFromMetadata(project.Metadata)
|
|
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
|
|
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
|
|
cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
|
|
sort.Slice(cls, func(i, j int) bool {
|
|
if cls[i].Status != cls[j].Status {
|
|
return cls[i].Status == "open"
|
|
}
|
|
return cls[i].Source < cls[j].Source
|
|
})
|
|
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
w := c.Writer
|
|
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
|
<title>Klaerungen — %s</title>
|
|
<style>
|
|
@page { size: A4; margin: 18mm 15mm; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.4; }
|
|
h1 { font-size: 16pt; margin: 0 0 4px 0; }
|
|
.sub { font-size: 9pt; color: #555; margin-bottom: 16px; }
|
|
.meta { font-size: 9pt; color: #444; margin-bottom: 12px; }
|
|
.bar { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8pt; margin-right: 4px; }
|
|
.open { background: #fed7aa; color: #7c2d12; }
|
|
.done { background: #bbf7d0; color: #14532d; }
|
|
.gray { background: #e5e7eb; color: #374151; }
|
|
section { page-break-inside: avoid; margin-bottom: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 10px 12px; }
|
|
section h2 { font-size: 10pt; margin: 0 0 2px 0; }
|
|
section .src { font-size: 8pt; color: #6b7280; margin-bottom: 6px; }
|
|
.q { font-weight: 600; font-size: 10.5pt; margin: 4px 0; }
|
|
.norm { font-size: 8pt; color: #555; }
|
|
.affected { font-size: 8pt; color: #555; margin: 4px 0; }
|
|
.answer { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 6px 8px; border-radius: 4px; font-size: 9pt; margin-top: 6px; }
|
|
.signrow { margin-top: 30px; display: flex; gap: 40px; }
|
|
.signrow div { flex: 1; border-top: 1px solid #6b7280; padding-top: 4px; font-size: 8pt; color: #6b7280; }
|
|
@media print { .noprint { display: none; } }
|
|
.noprint { background: #fef9c3; border: 1px solid #fde047; padding: 6px 10px; border-radius: 4px; margin-bottom: 12px; font-size: 9pt; }
|
|
</style></head><body>
|
|
<div class="noprint">Tipp: Mit <kbd>Strg+P</kbd> / <kbd>Cmd+P</kbd> als PDF speichern.</div>
|
|
<h1>Klaerungsliste — %s</h1>
|
|
<div class="sub">Projekt-ID %s · Stand %s</div>
|
|
<div class="meta">
|
|
<span class="bar open">%d offen</span>
|
|
<span class="bar done">%d beantwortet</span>
|
|
<span class="bar gray">%d gesamt</span>
|
|
</div>
|
|
`,
|
|
htmlEscape(project.MachineName),
|
|
htmlEscape(project.MachineName),
|
|
project.ID.String(),
|
|
time.Now().Format("2006-01-02 15:04"),
|
|
countByStatus(cls, false), countByStatus(cls, true), len(cls),
|
|
)
|
|
for _, cl := range cls {
|
|
statusCls := "open"
|
|
statusLabel := "Offen"
|
|
if cl.Status == "answered" {
|
|
statusCls, statusLabel = "done", "Beantwortet"
|
|
} else if cl.Status == "not_relevant" {
|
|
statusCls, statusLabel = "gray", "Nicht relevant"
|
|
} else if cl.Status == "in_progress" {
|
|
statusCls, statusLabel = "open", "In Klaerung"
|
|
}
|
|
fmt.Fprintf(w, `<section><div class="src">%s · <span class="bar %s">%s</span></div>
|
|
<h2>%s</h2>
|
|
`,
|
|
htmlEscape(cl.Source), statusCls, statusLabel,
|
|
htmlEscape(cl.Question),
|
|
)
|
|
if len(cl.NormReferences) > 0 {
|
|
fmt.Fprintf(w, `<div class="norm">Normen: %s</div>`, htmlEscape(strings.Join(cl.NormReferences, " | ")))
|
|
}
|
|
if len(cl.AffectedHazardNames) > 0 {
|
|
fmt.Fprintf(w, `<div class="affected">Betrifft %d Gefaehrdung(en): %s</div>`,
|
|
len(cl.AffectedHazardIDs),
|
|
htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")),
|
|
)
|
|
}
|
|
if cl.Status == "answered" || cl.Status == "not_relevant" {
|
|
fmt.Fprintf(w, `<div class="answer"><strong>Antwort (%s):</strong> %s`,
|
|
htmlEscape(cl.Answer),
|
|
htmlEscape(cl.Reasoning),
|
|
)
|
|
if cl.AnsweredBy != "" {
|
|
ts := cl.AnsweredAt
|
|
if len(ts) > 10 {
|
|
ts = ts[:10]
|
|
}
|
|
fmt.Fprintf(w, ` <em>— %s, %s</em>`, htmlEscape(cl.AnsweredBy), htmlEscape(ts))
|
|
}
|
|
fmt.Fprintf(w, `</div>`)
|
|
}
|
|
fmt.Fprintf(w, `</section>`)
|
|
}
|
|
fmt.Fprintf(w, `<div class="signrow"><div>Anlagenbauer · Datum · Unterschrift</div><div>Bediener · Datum · Unterschrift</div></div>`)
|
|
fmt.Fprintf(w, `</body></html>`)
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, `'`, "'")
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func countByStatus(cls []iace.Clarification, answered bool) int {
|
|
n := 0
|
|
for _, c := range cls {
|
|
isDone := c.Status == "answered" || c.Status == "not_relevant"
|
|
if isDone == answered {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|