feat(admin): add proceeding-type column to /admin/procedural-events list (t-paliad-321 / m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.

Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2

(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)

* internal/services/rule_editor_service.go —
  - LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
    paliad.proceeding_types WHERE id = ANY(...) for every distinct
    non-NULL proceeding_type_id in rows. Returns id → code map.
    Single round-trip, firm-wide reference data (no RLS / visibility
    gate). Used only by the LIST endpoint; GetByID etc. don't need it.

* internal/handlers/admin_rules.go —
  - adminRuleResponse gains ProceedingTypeCode *string field
    (json:"proceeding_type_code,omitempty"). Populated by
    wrapRuleListResponse from the id → code map.
  - handleAdminListRules calls LoadProceedingTypeCodes after fetching
    rows, passes the map to wrapRuleListResponse.

* frontend/src/admin-rules-list.tsx —
  - Adds Proceeding column header in position 2 (between Submission
    Code and Legal Citation) per paliadin's "Place between submission-
    code and the existing columns" spec. Binds to canonical i18n
    key admin.procedural_events.col.proceeding (added below).
  - Drops the legacy Verfahrenstyp column at position 4 — the new
    code-only column at position 2 replaces it; the old column
    showed `code · name` which duplicates the new content.

* frontend/src/client/admin-rules-list.ts —
  - Rule type gains proceeding_type_code?: string | null.
  - New proceedingCodeCell(r) helper: prefers server-side
    proceeding_type_code, falls back to dropdown-lookup
    proceedingLabel for defense-in-depth on older API responses
    (the old behaviour broke for rules whose proceeding_type_id
    pointed at non-fristenrechner category proceedings; the new
    column never has that bug because the join is server-side).
  - Row rendering: new <td class="admin-rules-col-proceeding"><code>
    proceedingCodeCell(r) </code></td> in column 2.

* frontend/src/client/i18n.ts —
  - admin.procedural_events.col.proceeding alias added for DE +
    EN ("Verfahren" / "Proceeding"). Mirror style of the other
    canonical aliases from Slice A.

* frontend/src/i18n-keys.ts —
  - Generated key union extended with
    "admin.procedural_events.col.proceeding".

Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
This commit is contained in:
mAi
2026-05-26 21:27:00 +02:00
parent 4cd28bc896
commit 6acb1167dd
6 changed files with 96 additions and 8 deletions

View File

@@ -102,9 +102,9 @@ export function renderAdminRulesList(): string {
<thead> <thead>
<tr> <tr>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th> <th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th> <th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th> <th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th> <th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th> <th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
<th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th> <th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th>

View File

@@ -11,6 +11,13 @@ import { initSidebar } from "./sidebar";
interface Rule { interface Rule {
id: string; id: string;
proceeding_type_id?: number | null; proceeding_type_id?: number | null;
// proceeding_type_code is the joined paliad.proceeding_types.code
// for proceeding_type_id, populated server-side by the
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
// a glance without depending on the FILTER-dropdown's limited
// proceeding list. NULL on event-rooted rules.
proceeding_type_code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule // submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from // within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`). // rule_code (the legal citation, e.g. `RoP.013.1`).
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
return `${pt.code} · ${name}`; return `${pt.code} · ${name}`;
} }
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
// the server-side joined proceeding_type_code when available
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
// for older API responses or for rules whose proceeding_type_id
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
// proceeding_type_id renders as the em-dash placeholder used
// elsewhere in the admin table.
function proceedingCodeCell(r: Rule): string {
if (r.proceeding_type_code) return r.proceeding_type_code;
if (r.proceeding_type_id == null) return "—";
return proceedingLabel(r.proceeding_type_id);
}
function buildFilterURL(): string { function buildFilterURL(): string {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding); if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
@@ -233,9 +253,9 @@ function renderRulesTable() {
tbody.innerHTML = rules.map((r) => ` tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row"> <tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td> <td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td> <td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td> <td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td> <td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td> <td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td> <td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>

View File

@@ -3120,6 +3120,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten", "admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt", "admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)", "admin.procedural_events.col.code": "Code (Verfahrensschritt)",
// t-paliad-321: 3-segment proceeding-type code column (joined
// server-side); disambiguates same-named rules across proceedings.
"admin.procedural_events.col.proceeding": "Verfahren",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad", "admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten", "admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)", "admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
@@ -6188,6 +6191,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Manage procedural events", "admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event", "admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)", "admin.procedural_events.col.code": "Code (procedural event)",
// t-paliad-321: 3-segment proceeding-type code column.
"admin.procedural_events.col.proceeding": "Proceeding",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad", "admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events", "admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)", "admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",

View File

@@ -297,6 +297,7 @@ export type I18nKey =
| "admin.partner_units.subtitle" | "admin.partner_units.subtitle"
| "admin.partner_units.title" | "admin.partner_units.title"
| "admin.procedural_events.col.code" | "admin.procedural_events.col.code"
| "admin.procedural_events.col.proceeding"
| "admin.procedural_events.edit.breadcrumb" | "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code" | "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind" | "admin.procedural_events.edit.field.event_kind"

View File

@@ -41,14 +41,22 @@ import (
// historical `submission_code` + `event_type` already on Rule's tags. // historical `submission_code` + `event_type` already on Rule's tags.
// The embedded *models.DeadlineRule carries every existing tag through // The embedded *models.DeadlineRule carries every existing tag through
// json.Marshal unchanged; the wrapper only ADDS the two new keys. // json.Marshal unchanged; the wrapper only ADDS the two new keys.
//
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
// /admin/procedural-events list disambiguate same-named rules at a glance
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
type adminRuleResponse struct { type adminRuleResponse struct {
*models.DeadlineRule *models.DeadlineRule
Code *string `json:"code,omitempty"` Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"` EventKind *string `json:"event_kind,omitempty"`
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
} }
// wrapRuleResponse builds the dual-emit wrapper from a service result. // wrapRuleResponse builds the dual-emit wrapper from a service result.
// Same values, two keys per concept — no semantic change. // Same values, two keys per concept — no semantic change. Pass a non-nil
// ptCode to populate the proceeding_type_code field; nil leaves it
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse { func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
if r == nil { if r == nil {
return adminRuleResponse{} return adminRuleResponse{}
@@ -61,11 +69,20 @@ func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
} }
// wrapRuleListResponse maps a slice of service results into the // wrapRuleListResponse maps a slice of service results into the
// dual-emit wrapper. Used by the LIST endpoint. // dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse { // optional id → code lookup populated by handleAdminListRules from a
// single batch query against paliad.proceeding_types; nil leaves
// every row's proceeding_type_code empty (the LIST endpoint always
// passes a populated map; other callers don't need it).
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
out := make([]adminRuleResponse, len(rows)) out := make([]adminRuleResponse, len(rows))
for i := range rows { for i := range rows {
out[i] = wrapRuleResponse(&rows[i]) out[i] = wrapRuleResponse(&rows[i])
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
out[i].ProceedingTypeCode = &code
}
}
} }
return out return out
} }
@@ -128,8 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err) writeRuleEditorError(w, err)
return return
} }
// t-paliad-321: batch-fetch proceeding_type.code for every rule
// row that carries a non-NULL proceeding_type_id, so the LIST
// response can show a Proceeding column without an N+1 join.
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
if err != nil {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w) adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows)) writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
} }
// GET /admin/api/rules/{id} // GET /admin/api/rules/{id}

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models" "mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
@@ -677,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
return rows, nil return rows, nil
} }
// LoadProceedingTypeCodes returns an id → code map for every distinct
// non-NULL proceeding_type_id present in rows. Single SELECT against
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
// /admin/api/procedural-events to enrich the LIST response with a
// proceeding_type_code field so the admin UI can disambiguate
// same-named rules at a glance (t-paliad-321).
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
seen := map[int]bool{}
var ids []int
for _, r := range rows {
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
seen[*r.ProceedingTypeID] = true
ids = append(ids, *r.ProceedingTypeID)
}
}
if len(ids) == 0 {
return nil, nil
}
type pair struct {
ID int `db:"id"`
Code string `db:"code"`
}
var pairs []pair
if err := s.db.SelectContext(ctx, &pairs,
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
pq.Array(ids),
); err != nil {
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
}
out := make(map[int]string, len(pairs))
for _, p := range pairs {
out[p.ID] = p.Code
}
return out, nil
}
// GetByID returns a single rule. Exported so the handler can call it // GetByID returns a single rule. Exported so the handler can call it
// directly without round-tripping through ListRules. // directly without round-tripping through ListRules.
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) { func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {