Files
paliad/internal/handlers/fristenrechner.go
m b54e938bdf feat(t-paliad-136): Phase B — card-click → calc panel → add to project
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
2026-05-05 14:04:54 +02:00

189 lines
7.2 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/services"
)
// Fristenrechner page handler: serves the static HTML. No DB dependency.
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/fristenrechner.html")
}
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
//
// Phase C: routes through FristenrechnerService which pulls rules from
// paliad.deadline_rules. When DATABASE_URL is unset, returns 503; the page
// itself still renders because it's static HTML.
func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
var req struct {
ProceedingType string `json:"proceedingType"`
TriggerDate string `json:"triggerDate"`
PriorityDate string `json:"priorityDate,omitempty"`
Flags []string `json:"flags,omitempty"`
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
return
}
if req.ProceedingType == "" || req.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "proceedingType und triggerDate sind erforderlich"})
return
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingType})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}
// POST /api/tools/fristenrechner/calculate-rule — single-rule calc for
// the v4 (t-paliad-136 Phase B) result-card click flow.
//
// Body: { ruleId? } OR { proceedingCode, ruleLocalCode }, plus
// triggerDate (YYYY-MM-DD, required) and flags? (string array,
// optional condition_flag inputs).
//
// Returns a RuleCalculation (see services.RuleCalculation) — the rule
// metadata + computed dueDate / originalDate / adjustmentReason. Used by
// the result-card calc panel; distinct from the full-timeline endpoint
// at POST /api/tools/fristenrechner.
func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
var req struct {
RuleID string `json:"ruleId"`
ProceedingCode string `json:"proceedingCode"`
RuleLocalCode string `json:"ruleLocalCode"`
TriggerDate string `json:"triggerDate"`
Flags []string `json:"flags,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
return
}
if req.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
return
}
if req.RuleID == "" && (req.ProceedingCode == "" || req.RuleLocalCode == "") {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "Entweder ruleId oder (proceedingCode + ruleLocalCode) ist erforderlich",
})
return
}
resp, err := dbSvc.fristenrechner.CalculateRule(r.Context(), services.CalcRuleParams{
RuleID: req.RuleID,
ProceedingCode: req.ProceedingCode,
RuleLocalCode: req.RuleLocalCode,
TriggerDate: req.TriggerDate,
Flags: req.Flags,
})
if err != nil {
switch {
case errors.Is(err, services.ErrUnknownRule):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannte Regel"})
case errors.Is(err, services.ErrUnknownProceedingType):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekannter Verfahrenstyp: " + req.ProceedingCode})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return
}
writeJSON(w, http.StatusOK, resp)
}
// GET /api/tools/proceeding-types — metadata list for the wizard buttons.
// Returns 503 with an empty array when DATABASE_URL is unset so the page
// still renders (buttons are server-rendered from tsx and don't depend on
// this endpoint for existence, only for dynamic list updates).
func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Verfahrenstypen vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
types, err := dbSvc.fristenrechner.ListFristenrechnerTypes(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"})
return
}
writeJSON(w, http.StatusOK, types)
}
// GET /api/tools/trigger-events — list active UPC trigger events for the
// "Was kommt nach…" mode picker. Sorted alphabetically by name.
func handleTriggerEventsList(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.eventDeadline == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Trigger-Ereignisse vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
events, err := dbSvc.eventDeadline.ListTriggerEvents(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Trigger-Ereignisse nicht laden"})
return
}
writeJSON(w, http.StatusOK, events)
}
// POST /api/tools/event-deadlines — compute all deadlines flowing from a
// trigger event + date. Body: {"triggerEventId": <int>, "triggerDate": "YYYY-MM-DD"}.
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.eventDeadline == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
var req struct {
TriggerEventID int64 `json:"triggerEventId"`
TriggerDate string `json:"triggerDate"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
return
}
if req.TriggerEventID <= 0 || req.TriggerDate == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerEventId und triggerDate sind erforderlich"})
return
}
resp, err := dbSvc.eventDeadline.Calculate(r.Context(), req.TriggerEventID, req.TriggerDate)
if err != nil {
if errors.Is(err, services.ErrUnknownTriggerEvent) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unbekanntes Trigger-Ereignis"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}