Compare commits
5 Commits
mai/artemi
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb99fb627 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a |
@@ -10,7 +10,6 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -22,9 +21,6 @@ interface Deadline {
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-258 — lawyer's free-text rule label when the deadline was
|
||||
// saved in Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -62,21 +58,7 @@ interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
|
||||
// when the user flips to Auto on the edit form.
|
||||
concept_default_event_type_id?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
jurisdiction: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -91,18 +73,6 @@ let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
|
||||
// On enterEdit we initialise the mode from the persisted deadline:
|
||||
// rule_id set → "auto"
|
||||
// custom_rule_text set, no rule_id → "custom"
|
||||
// neither set → "auto" (so the Type-driven
|
||||
// resolver fills in immediately).
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
@@ -211,66 +181,17 @@ function populateProjectPicker() {
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadAllRules() {
|
||||
async function loadRule(ruleID: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
if (!resp.ok) return;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
const all: DeadlineRule[] = await resp.json();
|
||||
rule = all.find((r) => r.id === ruleID) || null;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function lookupRule(ruleID: string): DeadlineRule | null {
|
||||
return rulesByID.get(ruleID) || null;
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType mirrors the create-form resolver: pick the
|
||||
// canonical rule for the chosen event_type, prioritising the project's
|
||||
// proceeding then jurisdiction match.
|
||||
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const projID = deadline?.project_id;
|
||||
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
|
||||
if (proj && proj.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypeByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
return resolveAutoRuleForType(picked[0]);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -322,15 +243,9 @@ function render() {
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
// t-paliad-258 — display priority:
|
||||
// 1. catalog rule (canonical Name · Citation pattern)
|
||||
// 2. custom_rule_text + Custom badge
|
||||
// 3. legacy rule_code-only (Fristenrechner saves)
|
||||
// 4. "—"
|
||||
if (rule) {
|
||||
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
|
||||
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
@@ -454,48 +369,6 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
@@ -510,10 +383,6 @@ function initEdit() {
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
const ruleDisplay = document.getElementById("deadline-rule-display");
|
||||
const ruleEdit = document.getElementById("deadline-rule-edit");
|
||||
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -530,19 +399,6 @@ function initEdit() {
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
|
||||
// from the persisted deadline. Display element stays visible so the
|
||||
// user keeps "before / after" context while editing.
|
||||
if (ruleEdit) ruleEdit.style.display = "";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "none";
|
||||
if (deadline?.custom_rule_text && !deadline.rule_id) {
|
||||
ruleMode = "custom";
|
||||
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
|
||||
} else {
|
||||
ruleMode = "auto";
|
||||
if (ruleCustomInput) ruleCustomInput.value = "";
|
||||
}
|
||||
applyRuleModeUI();
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -562,22 +418,11 @@ function initEdit() {
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
if (ruleEdit) ruleEdit.style.display = "none";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
|
||||
// time the Type picker changes, so just-toggling-to-Auto immediately
|
||||
// surfaces a fresh resolution.
|
||||
ruleToggleBtn?.addEventListener("click", () => {
|
||||
ruleMode = ruleMode === "auto" ? "custom" : "auto";
|
||||
applyRuleModeUI();
|
||||
if (ruleMode === "custom") ruleCustomInput?.focus();
|
||||
});
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
@@ -590,11 +435,8 @@ function initEdit() {
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip in edit)
|
||||
// || Auto-resolved rule's canonical label (Name · Citation)
|
||||
// || saved rule's canonical label
|
||||
// || custom_rule_text (when in Custom mode + non-empty)
|
||||
// || rule_code-only legacy fallback
|
||||
// head = event_type label (if exactly one Typ chip is in edit)
|
||||
// || rule code+name (when deadline carries a rule)
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
@@ -605,16 +447,9 @@ function initEdit() {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head) {
|
||||
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
|
||||
if (r) head = formatRuleLabel(r);
|
||||
}
|
||||
if (!head && ruleMode === "custom") {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = formatRuleLabel(rule);
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
head = code ? `${code} — ${rule.name}` : rule.name;
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
@@ -645,19 +480,6 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
// t-paliad-258 — rule_set discriminator tells the service this
|
||||
// PATCH carries an Auto/Custom rule change. Both columns are
|
||||
// mutually exclusive at the persistence boundary.
|
||||
payload.rule_set = true;
|
||||
if (ruleMode === "auto") {
|
||||
const r = currentAutoRule();
|
||||
payload.rule_id = r ? r.id : null;
|
||||
payload.custom_rule_text = null;
|
||||
} else {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
payload.rule_id = null;
|
||||
payload.custom_rule_text = txt || null;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
@@ -877,14 +699,8 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
loadProject(deadline.project_id),
|
||||
loadAllProjects(),
|
||||
loadPendingRequest(),
|
||||
loadAllRules(),
|
||||
loadProceedingTypes(),
|
||||
]);
|
||||
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
@@ -905,11 +721,6 @@ async function main() {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
onChange: () => {
|
||||
// Type change shifts the Auto-resolved rule. Refresh the
|
||||
// read-only display panel (no-op outside edit mode / Custom).
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,20 +8,25 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// Used by the Type→Rule resolver to narrow rule candidates to the
|
||||
// project's own proceeding when one applies. Optional because clients
|
||||
// and matter-level projects don't carry a proceeding type.
|
||||
// t-paliad-251 — used by Type→Rule autofill to narrow rule candidates
|
||||
// to the project's own proceeding. Optional because not every project
|
||||
// is a case/proceeding (clients + matters carry no proceeding type).
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
@@ -31,11 +36,14 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for the rule's concept. The
|
||||
// catalog is indexed by it so we can resolve Type → canonical Rule.
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule,
|
||||
// AND is inverted to power Typ→Regel auto-fill (t-paliad-251 Part 2):
|
||||
// given a chosen event_type X, candidate rules are those whose
|
||||
// concept_default_event_type_id === X.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
@@ -48,20 +56,31 @@ interface ProceedingType {
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
|
||||
// auto — rule_id resolved from the chosen event_type, rendered
|
||||
// read-only as "Auto: Name · Citation".
|
||||
// custom — free-text input; submits as custom_rule_text on the API.
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
// t-paliad-251 — symmetric flag for the inverse direction. Tracks the
|
||||
// rule ID we most recently injected as the Auto-derived default for the
|
||||
// chosen event_type, so we can replace it silently when the user picks
|
||||
// a different type but leave manual rule picks alone.
|
||||
let lastAutoFilledRuleID: string | null = null;
|
||||
|
||||
// Current sort mode for the Rule select. Persisted to localStorage so
|
||||
// repeat-form users don't have to re-pick their preferred ordering.
|
||||
type RuleSort = "by_proceeding" | "by_court" | "alpha";
|
||||
const RULE_SORT_KEY = "paliad.deadline.rule.sort";
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -75,6 +94,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function ruleLabel(r: DeadlineRule): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const code = r.rule_code || r.code || "";
|
||||
return code ? `${code} — ${name}` : name;
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
@@ -119,30 +145,143 @@ async function loadProceedingTypes() {
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
/* non-fatal — rule sort falls back to alpha when proceeding-type
|
||||
metadata is missing */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
renderRuleSelect();
|
||||
} catch {
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType picks the best-match catalog rule for the
|
||||
// chosen event type, scoring by:
|
||||
// renderRuleSelect rebuilds the Rule <select> from the current sort
|
||||
// mode + the cached rule set. Called whenever the user changes the sort
|
||||
// dropdown, when the language flips, or after rules + proceeding types
|
||||
// finish loading. The "Keine Regel" sentinel always stays at the top.
|
||||
function renderRuleSelect(): void {
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const previous = sel.value;
|
||||
|
||||
const sort = readRuleSort();
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
|
||||
if (sort === "alpha") {
|
||||
const sorted = [...allRules].sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
for (const r of sorted) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
} else if (sort === "by_court") {
|
||||
// Group by proceeding_type.jurisdiction (UPC / EPA / DPMA / DE /
|
||||
// other). Within each group, sort alpha by rule label so the user
|
||||
// can scan a court's rules in stable order.
|
||||
const byJurisdiction = new Map<string, DeadlineRule[]>();
|
||||
for (const r of allRules) {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
const j = pt?.jurisdiction || t("event_types.browse.jurisdiction.none");
|
||||
const list = byJurisdiction.get(j) ?? [];
|
||||
list.push(r);
|
||||
byJurisdiction.set(j, list);
|
||||
}
|
||||
const order = ["UPC", "EPA", "EPO", "DPMA", "DE"];
|
||||
const keys = [...byJurisdiction.keys()].sort((a, b) => {
|
||||
const ai = order.indexOf(a);
|
||||
const bi = order.indexOf(b);
|
||||
if (ai === -1 && bi === -1) return a.localeCompare(b);
|
||||
if (ai === -1) return 1;
|
||||
if (bi === -1) return -1;
|
||||
return ai - bi;
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = byJurisdiction.get(k)!.sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
|
||||
opts.push(`<optgroup label="${esc(k === "EPO" ? "EPA" : k)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
} else {
|
||||
// by_proceeding — group by proceeding_type, within each preserve the
|
||||
// canonical sequence_order so the user reads "Klageerwiderung →
|
||||
// Replik → Duplik → Verhandlung" in chronological order.
|
||||
const byProceeding = new Map<number | string, DeadlineRule[]>();
|
||||
const noProceedingKey = "__none__";
|
||||
for (const r of allRules) {
|
||||
const k: number | string = r.proceeding_type_id ?? noProceedingKey;
|
||||
const list = byProceeding.get(k) ?? [];
|
||||
list.push(r);
|
||||
byProceeding.set(k, list);
|
||||
}
|
||||
const keys = [...byProceeding.keys()].sort((a, b) => {
|
||||
if (a === noProceedingKey) return 1;
|
||||
if (b === noProceedingKey) return -1;
|
||||
const pa = proceedingTypesByID.get(a as number);
|
||||
const pb = proceedingTypesByID.get(b as number);
|
||||
const sa = pa?.sort_order ?? 9999;
|
||||
const sb = pb?.sort_order ?? 9999;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return (pa?.code ?? "").localeCompare(pb?.code ?? "");
|
||||
});
|
||||
for (const k of keys) {
|
||||
const list = (byProceeding.get(k)!).slice().sort(
|
||||
(a, b) => (a.sequence_order ?? 0) - (b.sequence_order ?? 0),
|
||||
);
|
||||
const pt = typeof k === "number" ? proceedingTypesByID.get(k) : undefined;
|
||||
const groupLabel = pt ? proceedingLabel(pt) : t("deadlines.field.rule.sort.other_proceeding");
|
||||
opts.push(`<optgroup label="${esc(groupLabel)}">`);
|
||||
for (const r of list) {
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
|
||||
}
|
||||
opts.push(`</optgroup>`);
|
||||
}
|
||||
}
|
||||
|
||||
sel.innerHTML = opts.join("");
|
||||
// Restore previous selection if it still exists in the new order.
|
||||
if (previous && rulesByID.has(previous)) {
|
||||
sel.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function readRuleSort(): RuleSort {
|
||||
try {
|
||||
const raw = localStorage.getItem(RULE_SORT_KEY);
|
||||
if (raw === "by_proceeding" || raw === "by_court" || raw === "alpha") return raw;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
return "by_court";
|
||||
}
|
||||
|
||||
function writeRuleSort(s: RuleSort): void {
|
||||
try {
|
||||
localStorage.setItem(RULE_SORT_KEY, s);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType picks the best-match rule for the chosen event
|
||||
// type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
|
||||
// jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise the first candidate in canonical sequence_order.
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's
|
||||
// proceeding's jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise just the first candidate in the canonical ordering.
|
||||
//
|
||||
// Returns null when no rule maps. Callers render that as "no Auto rule
|
||||
// available" so the user can flip to Custom or pick a different Type.
|
||||
// Returns null when no rule maps to this event_type. The caller surfaces
|
||||
// this as "no Auto rule available — pick one manually" rather than
|
||||
// silently leaving the dropdown stuck on whatever the user picked before.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
@@ -167,81 +306,231 @@ function resolveAutoRuleForType(eventTypeID: string, projectID: string): Deadlin
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// currentAutoRule returns the catalog rule the Auto mode would resolve
|
||||
// to for the current form state, or null when no Type is picked or no
|
||||
// rule maps. Centralised so the Auto display, submitForm, and the
|
||||
// Standardtitel button all agree on the same resolution.
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
return resolveAutoRuleForType(picked[0], projectID);
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
// refreshRuleAutoDisplay updates the read-only Auto display panel to
|
||||
// reflect the rule that would be saved in Auto mode. Hides itself when
|
||||
// the user is in Custom mode (the input takes its place).
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
// t-paliad-251 — when the rule was auto-derived from a user-picked
|
||||
// type (Typ→Regel direction), the collapsed "vorgegeben durch Regel"
|
||||
// copy reads backwards. Show the picker explicitly + surface the
|
||||
// Auto badge on the Rule field instead.
|
||||
const ruleWasAutoDerivedFromType =
|
||||
lastAutoFilledRuleID !== null && ruleID === lastAutoFilledRuleID;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault && !ruleWasAutoDerivedFromType;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function setRuleMode(mode: RuleMode): void {
|
||||
ruleMode = mode;
|
||||
applyRuleModeUI();
|
||||
if (mode === "custom") {
|
||||
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
warn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. canonical rule name (when Auto resolves to a rule)
|
||||
// 3. custom rule text (when in Custom mode)
|
||||
// 4. proceeding type name (when project carries one)
|
||||
// 5. fallback i18n key
|
||||
// Suffix: " — <project-reference>" when not already in head.
|
||||
// refreshRuleAutoBadgeAndWarning surfaces the Auto badge whenever the
|
||||
// Rule was derived from the Typ (i.e. lastAutoFilledRuleID is currently
|
||||
// selected) AND the warning whenever the user has manually picked a
|
||||
// non-Auto rule that contradicts the Type's derived rule. Both end up
|
||||
// inert when there's no Type chosen.
|
||||
function refreshRuleAutoBadgeAndWarning(): void {
|
||||
const autoEl = document.getElementById("deadline-rule-auto-hint");
|
||||
const autoTextEl = document.getElementById("deadline-rule-auto-hint-text");
|
||||
const warnEl = document.getElementById("deadline-rule-override-warn");
|
||||
if (!autoEl || !autoTextEl || !warnEl) return;
|
||||
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) {
|
||||
autoEl.style.display = "none";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
|
||||
if (currentRuleID && currentRuleID === lastAutoFilledRuleID) {
|
||||
// The current rule was auto-derived (and the user hasn't touched it).
|
||||
autoEl.style.display = "";
|
||||
autoTextEl.textContent = derived ? ` — ${ruleLabel(derived)}` : "";
|
||||
warnEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
autoEl.style.display = "none";
|
||||
|
||||
// Override warning: derived rule exists AND user has picked a
|
||||
// different non-empty rule. The copy names BOTH so the user knows
|
||||
// exactly what's happening — and which one will be applied.
|
||||
if (derived && currentRuleID && currentRuleID !== derived.id) {
|
||||
const current = rulesByID.get(currentRuleID);
|
||||
if (current) {
|
||||
const tmpl = t("deadlines.field.rule.override_warn");
|
||||
const msg = tmpl
|
||||
.replace("{derived}", ruleLabel(derived))
|
||||
.replace("{selected}", ruleLabel(current));
|
||||
warnEl.textContent = msg;
|
||||
warnEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
warnEl.style.display = "none";
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
// applyTypeAutoFillRule is the inverse direction (t-paliad-251 Part 2):
|
||||
// when the user picks a single Typ chip, derive the canonical Rule and
|
||||
// inject it into the Regel select. Like applyRuleAutoFill, it leaves
|
||||
// manual rule picks alone — only replaces when the current rule is the
|
||||
// previous auto-fill (sticky-replace pattern).
|
||||
function applyTypeAutoFillRule(): void {
|
||||
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
|
||||
if (!ruleSel) return;
|
||||
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
|
||||
if (picked.length !== 1) {
|
||||
// 0 or 2+ Typ chips → no canonical rule to derive. Clear the
|
||||
// sticky auto-fill so a stale Auto suggestion doesn't linger.
|
||||
if (lastAutoFilledRuleID && ruleSel.value === lastAutoFilledRuleID) {
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
// Mirror to the Regel→Typ path so its mismatch warning recomputes.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
return;
|
||||
}
|
||||
|
||||
const derived = resolveAutoRuleForType(picked[0], projectID);
|
||||
const currentRuleID = ruleSel.value || "";
|
||||
const ruleStillReflectsLastSuggestion =
|
||||
lastAutoFilledRuleID !== null && currentRuleID === lastAutoFilledRuleID;
|
||||
const ruleIsEmpty = currentRuleID === "";
|
||||
|
||||
if (derived) {
|
||||
if (ruleIsEmpty || ruleStillReflectsLastSuggestion) {
|
||||
ruleSel.value = derived.id;
|
||||
lastAutoFilledRuleID = derived.id;
|
||||
// Mirror to the Regel→Typ direction — the new rule's collapsed
|
||||
// view + mismatch state needs to recompute now that we changed
|
||||
// the selection programmatically.
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
} else if (ruleStillReflectsLastSuggestion) {
|
||||
// No derived rule for the new type — drop the stale auto-fill.
|
||||
ruleSel.value = "";
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
}
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
}
|
||||
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Recipe (documented also in
|
||||
// the commit message so future title templates can mirror it):
|
||||
//
|
||||
// priority order picks the head of the title:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. rule name (when a Rule is set — uses ruleLabel = "code — name")
|
||||
// 3. proceeding type name (when project carries a proceeding_type_id)
|
||||
// 4. fallback: t("deadlines.field.title.default_fallback")
|
||||
//
|
||||
// suffix: " — <project-reference>" when the project has a reference
|
||||
// string and the title doesn't already contain it.
|
||||
//
|
||||
// Returns "" only when even the fallback fails (i18n unavailable) —
|
||||
// callers handle that by leaving the field untouched.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
let head = "";
|
||||
@@ -249,15 +538,8 @@ function computeDefaultTitle(): string {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head) {
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) head = formatRuleLabel(rule);
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = ruleLabel(rule);
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
@@ -282,6 +564,7 @@ async function submitForm(e: Event) {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!projectID || !title || !due) {
|
||||
@@ -298,15 +581,7 @@ async function submitForm(e: Event) {
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
// Rule field: Auto resolves to rule_id, Custom sends the free text.
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) payload.rule_id = rule.id;
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) payload.custom_rule_text = txt;
|
||||
}
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
@@ -347,16 +622,6 @@ function detectPreselect() {
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -370,6 +635,8 @@ async function loadMe() {
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
@@ -388,6 +655,7 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
@@ -423,45 +691,78 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Wire the sort dropdown to read its initial value from localStorage and
|
||||
// persist user picks back.
|
||||
const sortSel = document.getElementById("deadline-rule-sort") as HTMLSelectElement | null;
|
||||
if (sortSel) {
|
||||
sortSel.value = readRuleSort();
|
||||
sortSel.addEventListener("change", () => {
|
||||
writeRuleSort(sortSel.value as RuleSort);
|
||||
renderRuleSelect();
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
// After both rules + proceeding types are in, re-render with the
|
||||
// chosen sort so groups carry proper labels.
|
||||
renderRuleSelect();
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => {
|
||||
// Type change shifts which Auto rule resolves; re-render the
|
||||
// read-only Auto display panel.
|
||||
refreshRuleAutoDisplay();
|
||||
// Both directions trigger off picker change: refresh the
|
||||
// Regel→Typ collapsed/expanded state AND the Typ→Regel auto-fill.
|
||||
refreshRuleView();
|
||||
applyTypeAutoFillRule();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Preload event_types for the Auto display + Standardtitel resolver.
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleAutoDisplay();
|
||||
refreshRuleView();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
})
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion. ALSO
|
||||
// resets the Typ→Regel auto-fill marker since the user just made a
|
||||
// manual rule pick.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
lastAutoFilledRuleID = null;
|
||||
applyRuleAutoFill();
|
||||
refreshRuleAutoBadgeAndWarning();
|
||||
});
|
||||
|
||||
applyRuleModeUI();
|
||||
|
||||
// Approval-hint refresh: on first render + on project change.
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
// Project change can shift which rule the Type maps to (via the
|
||||
// project's proceeding_type_id), so re-run the auto-fill.
|
||||
void refreshApprovalHint();
|
||||
// Project change can shift which Auto rule resolves (via the
|
||||
// project's proceeding_type_id).
|
||||
refreshRuleAutoDisplay();
|
||||
applyTypeAutoFillRule();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// t-paliad-251 Part 4 — Standardtitel button replaces the title with
|
||||
// a derived default. No destructive confirmation because the user
|
||||
// invoked it explicitly.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -67,9 +66,6 @@ interface EventListItem {
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was created
|
||||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
@@ -268,26 +264,13 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||||
// show the bare citation as last-resort fallback.
|
||||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||||
(item.rule_name_en && item.rule_name_en.trim());
|
||||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{
|
||||
name: item.rule_name || "",
|
||||
name_en: item.rule_name_en,
|
||||
rule_code: item.rule_code,
|
||||
},
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||||
}
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
return "—";
|
||||
}
|
||||
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -302,11 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv (Klägerseite)",
|
||||
"deadlines.col.proactive.defendant": "Proaktiv (Beklagtenseite)",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv (Beklagtenseite)",
|
||||
"deadlines.col.reactive.claimant": "Reaktiv (Klägerseite)",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -884,13 +882,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
|
||||
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
|
||||
"deadlines.field.rule.custom_badge": "Eigen",
|
||||
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
|
||||
"deadlines.field.rule.override_warn": "Typ ergibt Regel: {derived}. Gewählte Regel: {selected}. Es wird {selected} angewendet.",
|
||||
"deadlines.field.rule.sort.by_proceeding": "Nach Verfahrensablauf",
|
||||
"deadlines.field.rule.sort.by_court": "Nach Gerichtsart",
|
||||
"deadlines.field.rule.sort.alpha": "Alphabetisch",
|
||||
"deadlines.field.rule.sort.other_proceeding": "Sonstige Regeln",
|
||||
"deadlines.field.title.default_btn": "Standardtitel",
|
||||
"deadlines.field.title.default_fallback": "Neue Frist",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
@@ -3275,11 +3277,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive (Claimant side)",
|
||||
"deadlines.col.proactive.defendant": "Proactive (Defendant side)",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive (Defendant side)",
|
||||
"deadlines.col.reactive.claimant": "Reactive (Claimant side)",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3857,13 +3857,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "e.g. File statement of defence",
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
|
||||
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
|
||||
"deadlines.field.rule.custom_badge": "Custom",
|
||||
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
|
||||
"deadlines.field.rule.override_warn": "Type derives rule: {derived}. Selected rule: {selected}. {selected} will be applied.",
|
||||
"deadlines.field.rule.sort.by_proceeding": "By proceeding sequence",
|
||||
"deadlines.field.rule.sort.by_court": "By court type",
|
||||
"deadlines.field.rule.sort.alpha": "Alphabetical",
|
||||
"deadlines.field.rule.sort.other_proceeding": "Other rules",
|
||||
"deadlines.field.title.default_btn": "Default title",
|
||||
"deadlines.field.title.default_fallback": "New deadline",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -143,11 +142,6 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was saved in
|
||||
// Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
@@ -811,9 +805,6 @@ interface UnionEvent {
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
custom_rule_text?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
@@ -841,9 +832,6 @@ async function loadDeadlines(id: string) {
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
rule_name: it.rule_name,
|
||||
rule_name_en: it.rule_name_en,
|
||||
custom_rule_text: it.custom_rule_text,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
@@ -1013,27 +1001,6 @@ function fmtDateOnly(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeadlineRuleCell renders the REGEL column for the project
|
||||
// detail Fristen table using the canonical t-paliad-258 contract:
|
||||
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
|
||||
// 2. custom_rule_text → text + "Custom" badge
|
||||
// 3. legacy rule_code-only saves → bare citation
|
||||
// 4. otherwise "—"
|
||||
function formatDeadlineRuleCell(f: Deadline): string {
|
||||
const hasName = (f.rule_name && f.rule_name.trim()) ||
|
||||
(f.rule_name_en && f.rule_name_en.trim());
|
||||
if (hasName || (f.rule_code && f.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (f.custom_rule_text && f.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
@@ -1072,7 +1039,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// rule-label — canonical display contract for deadline rules.
|
||||
//
|
||||
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
|
||||
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
|
||||
// invented its own pattern: sometimes citation-only, sometimes name-only,
|
||||
// sometimes "code — name". m flagged this on the first submissions in a
|
||||
// proceeding sequence where the inconsistency was most visible.
|
||||
//
|
||||
// Canonical pattern: **Name primary, Citation muted secondary**.
|
||||
// Text: "Notice of Appeal · UPC.RoP.220.1"
|
||||
// HTML: <span class="rule-label-name">Notice of Appeal</span>
|
||||
// <span class="rule-label-sep"> · </span>
|
||||
// <span class="rule-label-cite">UPC.RoP.220.1</span>
|
||||
//
|
||||
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
|
||||
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
|
||||
// so list/detail surfaces can render both shapes uniformly.
|
||||
|
||||
import { getLang, t } from "./i18n";
|
||||
|
||||
export interface RuleLike {
|
||||
name: string;
|
||||
name_en?: string | null;
|
||||
// The catalog carries multiple citation fields depending on which
|
||||
// surface populated it. Order of preference: legal_source > rule_code
|
||||
// > code. All three are accepted so callers don't have to normalise.
|
||||
rule_code?: string | null;
|
||||
code?: string | null;
|
||||
legal_source?: string | null;
|
||||
}
|
||||
|
||||
// formatRuleLabel returns the canonical plain-text label.
|
||||
// Falls back gracefully when either side is missing.
|
||||
export function formatRuleLabel(r: RuleLike): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
return name || cite || "";
|
||||
}
|
||||
|
||||
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
|
||||
// styling. The caller passes the HTML-escape helper so we don't pull a
|
||||
// dependency on a specific esc() module — every surface already has one.
|
||||
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) {
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(name)}</span>` +
|
||||
`<span class="rule-label-sep"> · </span>` +
|
||||
`<span class="rule-label-cite">${esc(cite)}</span>`
|
||||
);
|
||||
}
|
||||
return esc(name || cite || "");
|
||||
}
|
||||
|
||||
// ruleCitation returns the best-available citation string for a rule.
|
||||
// Exported so callers that need the bare code (e.g. CalDAV exports,
|
||||
// inline data attributes) can pull it without going through the label
|
||||
// formatter.
|
||||
export function ruleCitation(r: RuleLike): string {
|
||||
return r.legal_source || r.rule_code || r.code || "";
|
||||
}
|
||||
|
||||
// formatCustomRuleLabelHTML — render a free-text custom rule label with
|
||||
// a "Custom" badge slot. Used by surfaces that may display either a
|
||||
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
|
||||
// the text is empty so callers can fall through to "—".
|
||||
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(trimmed)}</span>` +
|
||||
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// formatCustomRuleLabel — plain-text equivalent of the above.
|
||||
export function formatCustomRuleLabel(text: string | null | undefined): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return `${trimmed} · ${badge}`;
|
||||
}
|
||||
@@ -147,22 +147,8 @@ function formatColumn(row: ViewRow, col: string): string {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule": {
|
||||
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
||||
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
||||
const lang = getLang();
|
||||
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
||||
const name = (row.detail[nameKey] as string | undefined)
|
||||
|| (row.detail.rule_name as string | undefined)
|
||||
|| "";
|
||||
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
if (name) return name;
|
||||
if (cite) return cite;
|
||||
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
||||
if (custom.trim()) return `${custom} · Custom`;
|
||||
return "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
|
||||
@@ -67,17 +67,20 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour pinned by m/paliad#81. Hits
|
||||
// bucketDeadlinesIntoColumns directly so the assertions stay in
|
||||
// pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across proactive +
|
||||
// reactive. With ?appellant= set, the duplicate must collapse to a
|
||||
// single row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81)", () => {
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
@@ -96,39 +99,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into proactive AND reactive — legacy behaviour preserved", () => {
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into proactive only — no mirror", () => {
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.proactive.map((d) => d.name))).toEqual([
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.reactive).toHaveLength(0));
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into reactive only", () => {
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].proactive).toHaveLength(0);
|
||||
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant swaps which column owns claimant vs defendant rules", () => {
|
||||
// claimant filing must land in REACTIVE (claimant is the opposing
|
||||
// side from the defendant user's perspective), defendant filing in
|
||||
// PROACTIVE. Court rules always go to court.
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
@@ -137,20 +149,33 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].proactive.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into PROACTIVE (user's own column)", () => {
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == proactive after the swap.
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].reactive).toHaveLength(0);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
@@ -161,8 +186,8 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].proactive.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].reactive.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
@@ -172,7 +197,7 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.proactive, r.court, r.reactive].flat().map((d) => d.name))).toEqual([
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
|
||||
@@ -422,42 +422,47 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive | Court | Reactive.
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// Column assignment per deadline (see m/paliad#81):
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// - party=claimant → proactive
|
||||
// - party=defendant → reactive
|
||||
// - party=court → court
|
||||
// - party=both → BOTH proactive AND reactive (mirror).
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column. The intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where the
|
||||
// "both" tag really means "either party files, depending on who
|
||||
// initiated" — once you pick the initiator, the duplicate goes away.
|
||||
// Hard rule from the issue: "When set, 'both parties' rows collapse
|
||||
// to one row in the appellant's column." This is a UI projection
|
||||
// only; the deadline_rules schema is unchanged. A follow-up issue
|
||||
// can enrich per-rule role tagging so respondent-side filings
|
||||
// (Response to Appeal, Cross-Appeal) land in the respondent's
|
||||
// column — out of scope for #81.
|
||||
//
|
||||
// `opts.side` controls the column LABELS: side=defendant swaps the
|
||||
// "Proactive (Klägerseite)" / "Reactive (Beklagtenseite)" headers
|
||||
// so the user's own side is the proactive (= "your filings") column.
|
||||
// It does NOT filter deadlines — the user still sees all deadlines
|
||||
// in the proceeding. Default `side=null` keeps the legacy
|
||||
// claimant-on-the-left layout. Unscheduled (court-set) rows trail
|
||||
// the dated tail, each keyed by sequence-order so e.g. Urteil
|
||||
// precedes Berufungseinlegung.
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column-label swap;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left.
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
@@ -471,9 +476,9 @@ export interface ColumnsBodyOpts {
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
proactive: CalculatedDeadline[];
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
reactive: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
@@ -484,17 +489,20 @@ export interface BucketingOpts {
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81) is unit-testable without a DOM. The
|
||||
// returned rows are sorted: dated rows ascending by dueDate, then
|
||||
// unscheduled rows in declaration order (each keyed by sequence).
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const claimantColumn: "proactive" | "reactive" = userSide === "defendant" ? "reactive" : "proactive";
|
||||
const defendantColumn: "proactive" | "reactive" = claimantColumn === "proactive" ? "reactive" : "proactive";
|
||||
const appellantColumn: "proactive" | "reactive" | null =
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
@@ -504,7 +512,7 @@ export function bucketDeadlinesIntoColumns(
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { key, proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
@@ -529,8 +537,8 @@ export function bucketDeadlinesIntoColumns(
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -552,17 +560,14 @@ export function bucketDeadlinesIntoColumns(
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantColumn: "proactive" | "reactive" | null =
|
||||
opts.appellant === "claimant" ? (userSide === "defendant" ? "reactive" : "proactive")
|
||||
: opts.appellant === "defendant" ? (userSide === "defendant" ? "proactive" : "reactive")
|
||||
: null;
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = appellantColumn === null;
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
@@ -585,25 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Column-label swap when side=defendant: the user's own side stays
|
||||
// labelled "Proaktiv" (their filings) and the opposing side is
|
||||
// "Reaktiv". Default keeps the legacy claimant=proactive labels.
|
||||
const proactiveLabel = userSide === "defendant"
|
||||
? t("deadlines.col.proactive.defendant")
|
||||
: t("deadlines.col.proactive");
|
||||
const reactiveLabel = userSide === "defendant"
|
||||
? t("deadlines.col.reactive.claimant")
|
||||
: t("deadlines.col.reactive");
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(proactiveLabel, "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(reactiveLabel, "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -108,36 +108,7 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</span>
|
||||
{/* t-paliad-258 — Auto / Custom rule editor.
|
||||
Mirrors /deadlines/new: read-only Auto display
|
||||
(resolved from Type) or free-text Custom input,
|
||||
with a toggle link. Hidden outside edit mode. */}
|
||||
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
@@ -72,41 +72,91 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
</div>
|
||||
|
||||
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
|
||||
Auto (default): rule_id derived from the chosen
|
||||
Type, displayed read-only with a canonical
|
||||
"Name · Citation" label. Custom: free-text input,
|
||||
no catalog FK. Toggle switches modes. */}
|
||||
<div className="form-field">
|
||||
<div className="form-field-label-row">
|
||||
<label data-i18n="deadlines.field.rule">Regel</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
<div className="form-field">
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
{/* t-paliad-251 Part 2 — sort options for the Rule
|
||||
select. Defaults to "by_court" so users in the
|
||||
UPC bucket find UPC rules quickly. */}
|
||||
<select id="deadline-rule-sort" className="rule-sort-select" aria-label="Sortierung">
|
||||
<option value="by_proceeding" data-i18n="deadlines.field.rule.sort.by_proceeding">Nach Verfahrensablauf</option>
|
||||
<option value="by_court" data-i18n="deadlines.field.rule.sort.by_court" selected>Nach Gerichtsart</option>
|
||||
<option value="alpha" data-i18n="deadlines.field.rule.sort.alpha">Alphabetisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
{/* t-paliad-251 Part 3 — explicit Auto badge surfaces
|
||||
whenever the Rule was auto-derived from the Typ.
|
||||
Hidden when the user has manually picked a rule. */}
|
||||
<p
|
||||
className="form-hint form-hint--auto"
|
||||
id="deadline-rule-auto-hint"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
<span id="deadline-rule-auto-hint-text" />
|
||||
</p>
|
||||
{/* t-paliad-251 Part 3 — clearer override warning that
|
||||
names BOTH the type-derived rule and the actually-
|
||||
applied rule. Replaces the older Regel→Typ-only
|
||||
mismatch warning when the contradiction goes the
|
||||
other direction. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-rule-override-warn"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1153,10 +1153,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.proactive.defendant"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.reactive.claimant"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1245,12 +1243,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.auto_no_match"
|
||||
| "deadlines.field.rule.auto_pick_type"
|
||||
| "deadlines.field.rule.custom_badge"
|
||||
| "deadlines.field.rule.custom_placeholder"
|
||||
| "deadlines.field.rule.mode.toggle_to_auto"
|
||||
| "deadlines.field.rule.mode.toggle_to_custom"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.override_warn"
|
||||
| "deadlines.field.rule.sort.alpha"
|
||||
| "deadlines.field.rule.sort.by_court"
|
||||
| "deadlines.field.rule.sort.by_proceeding"
|
||||
| "deadlines.field.rule.sort.other_proceeding"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
|
||||
@@ -3629,7 +3629,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-proactive {
|
||||
.fr-col-header.fr-col-ours {
|
||||
background: var(--status-blue-bg);
|
||||
color: var(--status-blue-fg);
|
||||
}
|
||||
@@ -3639,7 +3639,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-blue-soft-fg);
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-reactive {
|
||||
.fr-col-header.fr-col-opponent {
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
@@ -5725,6 +5725,21 @@ dialog.modal::backdrop {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* t-paliad-260 — at single-column widths, drop the sticky/max-height
|
||||
constraints on the variable editor so it reflows above the preview
|
||||
and scrolls away naturally instead of overlaying the preview pane
|
||||
(sticky + calc(100vh - 2rem) keep the form pinned at the top of the
|
||||
viewport while the user scrolls down to read the preview). Must come
|
||||
after the unscoped .submission-draft-sidebar block to win source
|
||||
order at equal specificity. */
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-draft-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7612,35 +7627,15 @@ dialog.modal::backdrop {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
|
||||
Replaces the t-paliad-251 catalog dropdown + sort selector with a
|
||||
binary toggle:
|
||||
.rule-mode-auto — read-only display, lime-tint pill + label.
|
||||
.rule-mode-custom — free-text input, full-width.
|
||||
Toggle button reuses .btn-link-action for the inline link styling. */
|
||||
.rule-mode-auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
min-height: 2rem;
|
||||
}
|
||||
.rule-auto-text {
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.rule-auto-text--empty {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
}
|
||||
.form-field input.rule-mode-custom,
|
||||
input.rule-mode-custom {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
/* Small dropdown rendered alongside the Rule label to switch the
|
||||
ordering. Tone-down sizing so it doesn't look like a co-equal
|
||||
form field. Specificity-bumped to win over `.form-field select`'s
|
||||
width: 100% baseline. */
|
||||
.form-field select.rule-sort-select,
|
||||
select.rule-sort-select {
|
||||
width: auto;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
@@ -7648,34 +7643,6 @@ input.rule-mode-custom {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* t-paliad-258 addendum — canonical rule label display:
|
||||
Name primary, Citation muted secondary ("Name · Citation").
|
||||
Custom rules use a "Custom" pill instead of a citation. */
|
||||
.rule-label-name {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.rule-label-sep,
|
||||
.rule-label-cite {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.rule-label-cite {
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
.rule-label-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.02rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
/* Inline checkbox label inside the attach-unit form. */
|
||||
.form-checkbox {
|
||||
display: inline-flex;
|
||||
@@ -12279,10 +12246,42 @@ dialog.quick-add-sheet::backdrop {
|
||||
t-paliad-088 — Event Types: picker, multi-select filter, add modal
|
||||
============================================================================ */
|
||||
|
||||
/* (t-paliad-258 — the .event-type-collapsed* "vorgegeben durch Regel"
|
||||
collapsed view from t-paliad-165 was retired with the catalog
|
||||
dropdown. The Auto/Custom rule editor took its place; styles for
|
||||
that live under .rule-mode-auto / .rule-mode-custom above.) */
|
||||
/* t-paliad-165 follow-up — collapsed read-only view used on
|
||||
/deadlines/new when a Regel is selected and a default event_type is
|
||||
known. Replaces the picker with a single inline label + an
|
||||
"Anderen Typ wählen" override link. */
|
||||
.event-type-collapsed {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.event-type-collapsed-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-collapsed-source {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1d4ed8);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
|
||||
|
||||
/* Picker host — chip cluster + search + suggest dropdown */
|
||||
.event-type-picker {
|
||||
@@ -12679,7 +12678,8 @@ dialog.quick-add-sheet::backdrop {
|
||||
.event-type-browse-search:focus { border-color: var(--color-accent); }
|
||||
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
|
||||
header. Sits below the search input, between the search and the
|
||||
results list. Active chip uses the lime-tint chip palette. */
|
||||
results list. Active chip uses the lime-tint chip palette already
|
||||
established by .event-type-collapsed* (t-paliad-165). */
|
||||
.event-type-browse-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
-- t-paliad-258: revert the additive custom_rule_text column.
|
||||
-- Drop the column; rows that used the Custom path lose their free-text
|
||||
-- label and read as "no rule".
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS custom_rule_text;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
|
||||
-- deadline form.
|
||||
--
|
||||
-- t-paliad-251 shipped the form with a full deadline_rules catalog
|
||||
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
|
||||
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
|
||||
--
|
||||
-- 1. Auto — rule_id derived from the chosen event_type, displayed
|
||||
-- read-only.
|
||||
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
|
||||
-- stored here.
|
||||
--
|
||||
-- The column is additive + nullable: existing rows keep their
|
||||
-- deadline_rule_id and read as Auto-equivalent. A future row with both
|
||||
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS custom_rule_text text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
|
||||
'Free-text rule label entered when the lawyer chose Custom on the '
|
||||
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
|
||||
'the application layer: Auto path sets rule_id and leaves this '
|
||||
'NULL; Custom path sets this and leaves rule_id NULL. Display '
|
||||
'surfaces prefer the rule_id-joined deadline_rules.name when '
|
||||
'present, else fall back to custom_rule_text + a "Custom" badge.';
|
||||
@@ -65,8 +65,28 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
@@ -189,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
|
||||
@@ -904,16 +904,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// submission code. Lookup order matches the cronus design fallback chain
|
||||
// §8: per-firm template registered in submissionTemplateRegistry first,
|
||||
// then the universal HL Patents Style as the global fallback. The
|
||||
// returned SHA is the cache entry's commit SHA so the export audit row
|
||||
// can record provenance.
|
||||
// §8 plus the t-paliad-259 universal-skeleton slot:
|
||||
//
|
||||
// 1. per-firm per-submission_code template registered in
|
||||
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
|
||||
// specific structure plus the full variable bag.
|
||||
// 2. universal _skeleton.docx — same variable bag, no submission_code-
|
||||
// specific prose. Catches every code without a dedicated template
|
||||
// so the editor preview / generate flow still has variables to
|
||||
// substitute instead of falling through to the bare letterhead.
|
||||
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even the skeleton is unreachable
|
||||
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
|
||||
// for resilience.
|
||||
//
|
||||
// The returned SHA is the cache entry's commit SHA so the export audit
|
||||
// row can record provenance.
|
||||
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
|
||||
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
||||
return nil, "", err
|
||||
} else if found {
|
||||
return data, sha, nil
|
||||
}
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
|
||||
}
|
||||
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -313,14 +313,6 @@ type Deadline struct {
|
||||
// changes to paliad.deadline_rules and accepts citations from
|
||||
// outside that table.
|
||||
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
|
||||
// CustomRuleText holds the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
|
||||
// Mutually exclusive with RuleID at the application layer: the Auto
|
||||
// path sets RuleID and leaves this NULL; the Custom path sets this
|
||||
// and leaves RuleID NULL. Display surfaces prefer the joined
|
||||
// deadline_rules.name when RuleID is set, else fall back to this
|
||||
// text + a "Custom" badge.
|
||||
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
|
||||
Status string `db:"status" json:"status"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`
|
||||
|
||||
@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
|
||||
}
|
||||
|
||||
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
|
||||
notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at`
|
||||
|
||||
@@ -81,11 +81,6 @@ type CreateDeadlineInput struct {
|
||||
// Sent by the Fristenrechner save flow so the title can stay clean
|
||||
// instead of carrying the citation as a prefix.
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
// CustomRuleText is the lawyer's free-text rule label when the
|
||||
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
|
||||
// with RuleID at the application layer; the service trims and treats
|
||||
// an all-whitespace value as nil.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
@@ -113,20 +108,6 @@ type UpdateDeadlineInput struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
|
||||
// shapes; the service rejects "both set":
|
||||
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
|
||||
// bind to the catalog rule, clear custom_rule_text.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
|
||||
// store free text, clear rule_id.
|
||||
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
|
||||
// clear both columns.
|
||||
// RuleSet=false leaves both columns untouched (the rest of the
|
||||
// PATCH body doesn't carry rule changes).
|
||||
RuleSet bool `json:"rule_set,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
|
||||
@@ -260,7 +241,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
@@ -533,23 +514,6 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
}
|
||||
}
|
||||
|
||||
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
|
||||
// persistence boundary: setting one column NULLs the other.
|
||||
if input.RuleSet {
|
||||
if input.RuleID != nil && input.CustomRuleText != nil {
|
||||
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
appendSet("rule_id", input.RuleID)
|
||||
var customText *string
|
||||
if input.CustomRuleText != nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customText = &trimmed
|
||||
}
|
||||
}
|
||||
appendSet("custom_rule_text", customText)
|
||||
}
|
||||
|
||||
// Project move (t-paliad-140). Visibility on the destination is enforced
|
||||
// the same way as on Create — a GetByID round-trip through ProjectService
|
||||
// returns ErrNotVisible if the user can't see the target. Same-project
|
||||
@@ -623,7 +587,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
|
||||
// Did the PATCH touch anything beyond the project move?
|
||||
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
||||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
|
||||
input.EventTypeIDs != nil || input.RuleSet
|
||||
input.EventTypeIDs != nil
|
||||
if otherFieldsTouched {
|
||||
auditProject := current.ProjectID
|
||||
if movedFromProject != nil {
|
||||
@@ -1048,27 +1012,15 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
|
||||
}
|
||||
}
|
||||
|
||||
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
|
||||
// mutually exclusive. If the caller passes both, the catalog rule
|
||||
// wins and the free-text is dropped — keeps the invariant simple at
|
||||
// the persistence boundary.
|
||||
var customRuleText *string
|
||||
if input.CustomRuleText != nil && input.RuleID == nil {
|
||||
trimmed := strings.TrimSpace(*input.CustomRuleText)
|
||||
if trimmed != "" {
|
||||
customRuleText = &trimmed
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, description, due_date, original_due_date,
|
||||
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
|
||||
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
|
||||
id, projectID, title, input.Description, due, orig,
|
||||
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
|
||||
source, input.RuleID, ruleCode, input.Notes, userID, now,
|
||||
); err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
|
||||
}
|
||||
|
||||
@@ -107,15 +107,11 @@ type EventListItem struct {
|
||||
Status *string `json:"status,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
// CustomRuleText surfaces the lawyer's free-text rule label when the
|
||||
// deadline was created via the Custom rule path (t-paliad-258).
|
||||
// Display surfaces fall back to it when RuleName is absent.
|
||||
CustomRuleText *string `json:"custom_rule_text,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
|
||||
// Appointment-only.
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
@@ -240,7 +236,6 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
||||
RuleCode: d.RuleCode,
|
||||
RuleName: d.RuleName,
|
||||
RuleNameEN: d.RuleNameEN,
|
||||
CustomRuleText: d.CustomRuleText,
|
||||
EventTypeIDs: d.EventTypeIDs,
|
||||
}
|
||||
}
|
||||
|
||||
303
scripts/gen-skeleton-submission-template/main.go
Normal file
303
scripts/gen-skeleton-submission-template/main.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Universal-skeleton submission template generator (t-paliad-259).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves — without baking in any submission_code-specific prose.
|
||||
//
|
||||
// Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain
|
||||
// slotted between the per-submission_code template and the bare
|
||||
// universal HL Patents Style .dotm. Any submission_code that has no
|
||||
// per-firm template still gets a draft populated with variables
|
||||
// instead of the macro-only letterhead.
|
||||
//
|
||||
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
|
||||
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
|
||||
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
|
||||
// appeal, that body structure is wrong. The universal skeleton drops
|
||||
// the structure and leaves a single neutral body block the lawyer
|
||||
// replaces — every variable still resolves regardless of code.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
|
||||
//
|
||||
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
|
||||
// timestamp).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}}")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Sache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
plain(&b, "")
|
||||
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries (today.long_en, deadline.due_date_long_en,
|
||||
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user