The Auto-mode resolved rule name was rendered as an inline-flex pill that sat visually crammed next to the [Eigene Regel eingeben] toggle. Promote .rule-mode-auto to a full-width block-level flex row (width: 100%, margin-top: 0.35rem) so it sits cleanly on its own line beneath the toggle, and render the rule label via the canonical formatRuleLabelHTML helper so the citation gets the muted-secondary styling from rule-label.ts. Applies to both /deadlines/new and /deadlines/:id edit form. Custom mode (free-text input) is unaffected — the input already filled the column. Refs: m/paliad#98 (t-paliad-267), addendum to t-paliad-258 / m/paliad#89.
938 lines
33 KiB
TypeScript
938 lines
33 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import { initNotes } from "./notes";
|
|
import { projectIndent } from "./project-indent";
|
|
import {
|
|
attachEventTypePicker,
|
|
fetchEventTypes,
|
|
eventTypeLabel,
|
|
type EventType,
|
|
type PickerHandle,
|
|
} from "./event-types";
|
|
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
|
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
|
|
|
interface Deadline {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
description?: string;
|
|
due_date: string;
|
|
status: string;
|
|
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;
|
|
event_type_ids?: string[];
|
|
// t-paliad-138 + t-paliad-160. approval_status='pending' means an
|
|
// approval_request is in flight; pending_request_id resolves to it
|
|
// and the controls flip to a withdraw affordance for the requester.
|
|
approval_status?: "approved" | "pending" | "legacy";
|
|
pending_request_id?: string | null;
|
|
}
|
|
|
|
interface PendingApprovalRequest {
|
|
id: string;
|
|
status: string;
|
|
requested_by: string;
|
|
requested_at: string;
|
|
required_role: string;
|
|
requester_name?: string;
|
|
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
|
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
|
lifecycle_event?: string;
|
|
}
|
|
|
|
let eventTypePicker: PickerHandle | null = null;
|
|
let eventTypeByID: Map<string, EventType> = new Map();
|
|
|
|
interface Project {
|
|
id: string;
|
|
reference?: string | null;
|
|
title: string;
|
|
path?: string;
|
|
}
|
|
|
|
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 {
|
|
id: string;
|
|
job_title: string | null;
|
|
global_role: string;
|
|
}
|
|
|
|
let deadline: Deadline | null = null;
|
|
let project: Project | null = null;
|
|
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
|
|
// pending + merges the new fields into payload) instead of the regular
|
|
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
|
// from edit mode + after a successful save.
|
|
let pendingEditMode = false;
|
|
|
|
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
|
// modal handler (initWithdraw) can route into pending-edit mode without
|
|
// duplicating the edit-mode toggle logic.
|
|
let pendingEnterEdit: (() => void) | null = null;
|
|
|
|
function parseDeadlineID(): string | null {
|
|
const parts = window.location.pathname.split("/").filter(Boolean);
|
|
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
|
return parts[1];
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function fmtDate(iso: string): string {
|
|
try {
|
|
const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
|
|
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function fmtDateTime(iso: string): string {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function urgencyClass(due: string, status: string): string {
|
|
if (status === "completed") return "frist-urgency-done";
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const d = new Date(due.slice(0, 10) + "T00:00:00");
|
|
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
|
if (diffDays < 0) return "frist-urgency-overdue";
|
|
if (diffDays <= 7) return "frist-urgency-soon";
|
|
return "frist-urgency-later";
|
|
}
|
|
|
|
async function loadDeadline(id: string): Promise<boolean> {
|
|
try {
|
|
const resp = await fetch(`/api/deadlines/${id}`);
|
|
if (!resp.ok) return false;
|
|
deadline = await resp.json();
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loadProject(projectID: string) {
|
|
try {
|
|
const resp = await fetch(`/api/projects/${projectID}`);
|
|
if (resp.ok) project = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
async function loadAllProjects() {
|
|
try {
|
|
const resp = await fetch("/api/projects");
|
|
if (resp.ok) allProjects = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
function populateProjectPicker() {
|
|
const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
|
if (!sel || !deadline) return;
|
|
const opts: string[] = [];
|
|
for (const p of allProjects) {
|
|
const indent = projectIndent(p.path);
|
|
const ref = p.reference || "";
|
|
opts.push(
|
|
`<option value="${esc(p.id)}">${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
|
);
|
|
}
|
|
sel.innerHTML = opts.join("");
|
|
sel.value = deadline.project_id;
|
|
}
|
|
|
|
async function loadAllRules() {
|
|
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]));
|
|
} 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");
|
|
if (resp.ok) me = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
// loadPendingRequest hydrates the in-flight approval_request when the
|
|
// entity carries approval_status='pending'. Used to populate the badge
|
|
// tooltip + decide whether to show the Withdraw button (only the
|
|
// requester can withdraw).
|
|
async function loadPendingRequest(): Promise<void> {
|
|
pendingRequest = null;
|
|
if (!deadline || deadline.approval_status !== "pending" || !deadline.pending_request_id) {
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(`/api/approval-requests/${deadline.pending_request_id}`);
|
|
if (resp.ok) pendingRequest = await resp.json();
|
|
} catch {
|
|
/* non-fatal — badge still renders without the tooltip details */
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
if (!deadline) return;
|
|
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
|
|
(document.getElementById("deadline-title-edit") as HTMLInputElement).value = deadline.title;
|
|
|
|
const dueChip = document.getElementById("deadline-due-chip")!;
|
|
dueChip.className = `frist-due-chip ${urgencyClass(deadline.due_date, deadline.status)}`;
|
|
dueChip.textContent = fmtDate(deadline.due_date);
|
|
(document.getElementById("deadline-due-display") as HTMLElement).textContent = fmtDate(deadline.due_date);
|
|
(document.getElementById("deadline-due-edit") as HTMLInputElement).value = deadline.due_date.slice(0, 10);
|
|
|
|
const statusChip = document.getElementById("deadline-status-chip")!;
|
|
statusChip.className = `entity-status-chip entity-status-${deadline.status}`;
|
|
statusChip.textContent = tDyn(`deadlines.status.${deadline.status}`) || deadline.status;
|
|
|
|
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
|
if (project) {
|
|
projectLink.href = `/projects/${project.id}`;
|
|
projectLink.textContent = `${project.reference || ""} — ${project.title}`;
|
|
} else {
|
|
projectLink.href = `/projects/${deadline.project_id}`;
|
|
projectLink.textContent = "—";
|
|
}
|
|
|
|
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);
|
|
} 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).
|
|
ruleEl.textContent = deadline.rule_code;
|
|
} else {
|
|
ruleEl.textContent = "—";
|
|
}
|
|
|
|
(document.getElementById("deadline-source-display") as HTMLElement).textContent =
|
|
tDyn(`deadlines.source.${deadline.source}`) || deadline.source;
|
|
|
|
(document.getElementById("deadline-notes-display") as HTMLElement).textContent = deadline.notes || "—";
|
|
(document.getElementById("deadline-notes-edit") as HTMLTextAreaElement).value = deadline.notes || "";
|
|
|
|
// Event-Type display & picker (display always, picker only in edit mode).
|
|
const etDisplay = document.getElementById("deadline-event-types-display");
|
|
if (etDisplay) {
|
|
const ids = deadline.event_type_ids ?? [];
|
|
if (ids.length === 0) {
|
|
etDisplay.innerHTML = "—";
|
|
} else {
|
|
etDisplay.innerHTML = ids
|
|
.map((id) => {
|
|
const et = eventTypeByID.get(id);
|
|
if (!et) return "";
|
|
return `<span class="entity-event-type-pill">${esc(eventTypeLabel(et))}</span>`;
|
|
})
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
if (etDisplay.innerHTML === "") etDisplay.innerHTML = "—";
|
|
}
|
|
}
|
|
if (eventTypePicker) {
|
|
eventTypePicker.setIDs(deadline.event_type_ids ?? []);
|
|
}
|
|
|
|
(document.getElementById("deadline-created-display") as HTMLElement).textContent = fmtDateTime(deadline.created_at);
|
|
|
|
const completedLabel = document.getElementById("deadline-completed-row-label")!;
|
|
const completedDD = document.getElementById("deadline-completed-display")!;
|
|
if (deadline.completed_at) {
|
|
completedLabel.style.display = "";
|
|
completedDD.style.display = "";
|
|
completedDD.textContent = fmtDateTime(deadline.completed_at);
|
|
} else {
|
|
completedLabel.style.display = "none";
|
|
completedDD.style.display = "none";
|
|
}
|
|
|
|
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
|
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
|
const withdrawBtn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement;
|
|
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
|
const badge = document.getElementById("deadline-pending-approval-badge") as HTMLElement | null;
|
|
|
|
// t-paliad-160 §C+E — approval_status='pending' freezes the action
|
|
// controls and surfaces the badge + a Withdraw button (visible only to
|
|
// the requester). Other authenticated viewers see only the badge.
|
|
const isPending = deadline.approval_status === "pending";
|
|
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
|
|
|
|
if (badge) {
|
|
if (isPending) {
|
|
badge.style.display = "";
|
|
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
|
|
badge.textContent = labelDe;
|
|
// Tooltip carries requester + required_role + age (best-effort).
|
|
if (pendingRequest) {
|
|
const role = tDyn(`approvals.required_role.${pendingRequest.required_role}`) || pendingRequest.required_role;
|
|
const requester = pendingRequest.requester_name || pendingRequest.requested_by;
|
|
const when = fmtDateTime(pendingRequest.requested_at);
|
|
badge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
|
|
} else {
|
|
badge.title = labelDe;
|
|
}
|
|
} else {
|
|
badge.style.display = "none";
|
|
badge.title = "";
|
|
}
|
|
}
|
|
|
|
// Buttons.
|
|
if (deadline.status === "completed") {
|
|
completeBtn.style.display = "none";
|
|
if (me && (me.global_role === "global_admin") && !isPending) {
|
|
reopenBtn.style.display = "";
|
|
reopenBtn.disabled = false;
|
|
} else {
|
|
reopenBtn.style.display = "none";
|
|
}
|
|
} else if (isPending) {
|
|
// Lifecycle frozen — server returns 409 to anyone who tries.
|
|
completeBtn.style.display = "none";
|
|
reopenBtn.style.display = "none";
|
|
} else {
|
|
completeBtn.style.display = "";
|
|
completeBtn.disabled = false;
|
|
completeBtn.textContent = t("deadlines.detail.complete");
|
|
reopenBtn.style.display = "none";
|
|
}
|
|
|
|
// Edit button: hidden during pending so users don't fight a 409.
|
|
if (editBtn) editBtn.style.display = isPending ? "none" : "";
|
|
|
|
// Withdraw button: visible only when caller is the requester of the
|
|
// in-flight request.
|
|
if (withdrawBtn) {
|
|
if (isPending && isRequester) {
|
|
withdrawBtn.style.display = "";
|
|
withdrawBtn.disabled = false;
|
|
} else {
|
|
withdrawBtn.style.display = "none";
|
|
}
|
|
}
|
|
|
|
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
|
if (me && (me.global_role === "global_admin") && !isPending) {
|
|
deleteWrap.style.display = "";
|
|
} else {
|
|
deleteWrap.style.display = "none";
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Canonical "Name · Citation" with muted citation (t-paliad-258 addendum).
|
|
text.innerHTML = formatRuleLabelHTML(r, esc);
|
|
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;
|
|
const dueDisplay = document.getElementById("deadline-due-display")!;
|
|
const dueEdit = document.getElementById("deadline-due-edit") as HTMLInputElement;
|
|
const notesDisplay = document.getElementById("deadline-notes-display")!;
|
|
const notesEdit = document.getElementById("deadline-notes-edit") as HTMLTextAreaElement;
|
|
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
|
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
|
|
const etDisplay = document.getElementById("deadline-event-types-display");
|
|
const etEdit = document.getElementById("deadline-event-types-edit");
|
|
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";
|
|
titleEdit.style.display = "";
|
|
dueDisplay.style.display = "none";
|
|
dueEdit.style.display = "";
|
|
notesDisplay.style.display = "none";
|
|
notesEdit.style.display = "";
|
|
if (etDisplay) etDisplay.style.display = "none";
|
|
if (etEdit) etEdit.style.display = "";
|
|
if (projectEdit && deadline) {
|
|
projectLink.style.display = "none";
|
|
projectEdit.style.display = "";
|
|
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();
|
|
titleEdit.select();
|
|
}
|
|
function exitEdit() {
|
|
titleDisplay.style.display = "";
|
|
titleEdit.style.display = "none";
|
|
dueDisplay.style.display = "";
|
|
dueEdit.style.display = "none";
|
|
notesDisplay.style.display = "";
|
|
notesEdit.style.display = "none";
|
|
if (etDisplay) etDisplay.style.display = "";
|
|
if (etEdit) etEdit.style.display = "none";
|
|
if (projectEdit) {
|
|
projectEdit.style.display = "none";
|
|
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).
|
|
pendingEnterEdit = () => {
|
|
pendingEditMode = true;
|
|
enterEdit();
|
|
};
|
|
|
|
editBtn.addEventListener("click", enterEdit);
|
|
|
|
// 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
|
|
// || "Neue Frist" fallback
|
|
// suffix = " — <project.reference>" when not already in head
|
|
titleDefaultBtn?.addEventListener("click", () => {
|
|
if (!deadline) return;
|
|
let head = "";
|
|
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
|
if (ids.length === 1) {
|
|
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);
|
|
}
|
|
if (!head && deadline.rule_code) {
|
|
head = deadline.rule_code;
|
|
}
|
|
if (!head) head = t("deadlines.field.title.default_fallback");
|
|
const ref = project?.reference?.trim() || "";
|
|
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
|
titleEdit.value = head;
|
|
titleEdit.focus();
|
|
});
|
|
|
|
saveBtn.addEventListener("click", async () => {
|
|
if (!deadline) return;
|
|
const newTitle = titleEdit.value.trim();
|
|
const newDue = dueEdit.value;
|
|
const newNotes = notesEdit.value;
|
|
if (!newTitle || !newDue) return;
|
|
saveBtn.disabled = true;
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
title: newTitle,
|
|
due_date: newDue,
|
|
notes: newNotes,
|
|
};
|
|
if (eventTypePicker) {
|
|
payload.event_type_ids = eventTypePicker.getIDs();
|
|
}
|
|
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
|
|
// approval_request. Outside pending-edit mode the regular PATCH
|
|
// path remains the authoritative one (with its existing 409-on-
|
|
// pending guard).
|
|
if (pendingEditMode && pendingRequest) {
|
|
const resp = await fetch(
|
|
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fields: payload }),
|
|
},
|
|
);
|
|
if (resp.ok) {
|
|
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
|
if (fresh.ok) deadline = await fresh.json();
|
|
await loadPendingRequest();
|
|
render();
|
|
} else {
|
|
const body = await resp.json().catch(() => null);
|
|
const msg = (body && (body.message || body.error))
|
|
|| (t("approvals.withdraw.error") || "Fehler");
|
|
window.alert(msg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (resp.ok) {
|
|
const prevProjectID = deadline.project_id;
|
|
deadline = await resp.json();
|
|
if (deadline && deadline.project_id !== prevProjectID) {
|
|
await loadProject(deadline.project_id);
|
|
}
|
|
render();
|
|
}
|
|
} finally {
|
|
saveBtn.disabled = false;
|
|
exitEdit();
|
|
}
|
|
});
|
|
}
|
|
|
|
function initComplete() {
|
|
const btn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
|
btn.addEventListener("click", async () => {
|
|
if (!deadline || deadline.status === "completed") return;
|
|
btn.disabled = true;
|
|
try {
|
|
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
|
|
if (resp.ok) {
|
|
deadline = await resp.json();
|
|
// The complete may have created an approval_request rather than
|
|
// completed the deadline outright (4-eye-required). Re-fetch the
|
|
// entity + pending request to surface the right state.
|
|
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
|
if (fresh.ok) deadline = await fresh.json();
|
|
await loadPendingRequest();
|
|
render();
|
|
} else if (resp.status === 409) {
|
|
// The handler returns the t-paliad-160 §B body shape. Surface
|
|
// the human message and refresh state — likely a concurrent
|
|
// request was already in flight.
|
|
const body = await resp.json().catch(() => null);
|
|
const msg = (body && body.message) || t("approvals.error.awaiting_approval") || "Diese Anforderung wartet auf Genehmigung.";
|
|
window.alert(msg);
|
|
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
|
if (fresh.ok) {
|
|
deadline = await fresh.json();
|
|
await loadPendingRequest();
|
|
}
|
|
render();
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
} catch {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function initReopen() {
|
|
const btn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
|
btn.addEventListener("click", async () => {
|
|
if (!deadline || deadline.status !== "completed") return;
|
|
btn.disabled = true;
|
|
try {
|
|
const resp = await fetch(`/api/deadlines/${deadline.id}/reopen`, { method: "PATCH" });
|
|
if (resp.ok) {
|
|
deadline = await resp.json();
|
|
render();
|
|
} else {
|
|
btn.disabled = false;
|
|
}
|
|
} catch {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
|
//
|
|
// Click flow: open the withdraw warning modal (replaces the old
|
|
// confirm()). The modal returns one of:
|
|
//
|
|
// "edit" — open the edit form in pending-edit mode; Save calls
|
|
// /api/approval-requests/{id}/edit-entity which keeps the
|
|
// request pending + merges the new fields into payload
|
|
// "withdraw" — destructive: call the existing /revoke endpoint
|
|
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
|
// cancel-delete for DELETE lifecycle)
|
|
// null — user cancelled; nothing happens
|
|
function initWithdraw() {
|
|
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
btn.addEventListener("click", async () => {
|
|
if (!deadline || !pendingRequest) return;
|
|
btn.disabled = true;
|
|
try {
|
|
const action = await openWithdrawWarningModal({
|
|
entityType: "deadline",
|
|
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
|
});
|
|
if (action === null) {
|
|
btn.disabled = false;
|
|
return;
|
|
}
|
|
if (action === "edit") {
|
|
btn.disabled = false;
|
|
pendingEnterEdit?.();
|
|
return;
|
|
}
|
|
// action === "withdraw" → existing destructive path.
|
|
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
if (resp.ok) {
|
|
// Re-fetch the entity so approval_status flips back to 'approved'
|
|
// and the badge / buttons rerender accordingly. For CREATE
|
|
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
|
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
|
if (r.ok) {
|
|
deadline = await r.json();
|
|
await loadPendingRequest();
|
|
render();
|
|
} else {
|
|
// CREATE lifecycle deleted the entity — bounce to the list.
|
|
window.location.href = "/events?type=deadline";
|
|
}
|
|
} else {
|
|
btn.disabled = false;
|
|
const body = await resp.json().catch(() => null);
|
|
const msg = (body && (body.message || body.error)) || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
|
|
window.alert(msg);
|
|
}
|
|
} catch (e) {
|
|
btn.disabled = false;
|
|
window.alert((t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function initDelete() {
|
|
const btn = document.getElementById("deadline-delete-btn")!;
|
|
const modal = document.getElementById("deadline-delete-modal")!;
|
|
const close = document.getElementById("deadline-delete-modal-close")!;
|
|
const cancel = document.getElementById("deadline-delete-modal-cancel")!;
|
|
const confirmBtn = document.getElementById("deadline-delete-modal-confirm") as HTMLButtonElement;
|
|
|
|
btn.addEventListener("click", () => {
|
|
modal.style.display = "flex";
|
|
});
|
|
const closeModal = () => {
|
|
modal.style.display = "none";
|
|
};
|
|
close.addEventListener("click", closeModal);
|
|
cancel.addEventListener("click", closeModal);
|
|
modal.addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) closeModal();
|
|
});
|
|
confirmBtn.addEventListener("click", async () => {
|
|
if (!deadline) return;
|
|
confirmBtn.disabled = true;
|
|
const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "DELETE" });
|
|
if (resp.ok) {
|
|
const target = project ? `/projects/${project.id}/deadlines` : "/events?type=deadline";
|
|
window.location.href = target;
|
|
} else {
|
|
confirmBtn.disabled = false;
|
|
closeModal();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const id = parseDeadlineID();
|
|
const loading = document.getElementById("deadline-loading")!;
|
|
const notfound = document.getElementById("deadline-notfound")!;
|
|
const body = document.getElementById("deadline-body")!;
|
|
if (!id) {
|
|
loading.style.display = "none";
|
|
notfound.style.display = "block";
|
|
return;
|
|
}
|
|
await loadMe();
|
|
const ok = await loadDeadline(id);
|
|
if (!ok || !deadline) {
|
|
loading.style.display = "none";
|
|
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);
|
|
|
|
// 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
|
|
// next render() call after data lands).
|
|
try {
|
|
const types = await fetchEventTypes();
|
|
eventTypeByID = new Map(types.map((et) => [et.id, et]));
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
|
|
loading.style.display = "none";
|
|
body.style.display = "";
|
|
|
|
// Mount the picker (hidden until enterEdit()).
|
|
const pickerHost = document.getElementById("deadline-event-types-edit");
|
|
if (pickerHost) {
|
|
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();
|
|
},
|
|
});
|
|
}
|
|
|
|
populateProjectPicker();
|
|
render();
|
|
initEdit();
|
|
initComplete();
|
|
initReopen();
|
|
initWithdraw();
|
|
initDelete();
|
|
|
|
const notes = document.getElementById("notes-container");
|
|
if (notes) {
|
|
notes.setAttribute("data-parent-id", id);
|
|
void initNotes(notes as HTMLElement, "deadline", id);
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
onLangChange(render);
|
|
main();
|
|
});
|