A3 — admin/approval-policies 2-control flip:
Each cell becomes [✓] requires_approval checkbox + role select + clear
button. The "none" option in the role dropdown is gone — the checkbox
replaces it. Role select is greyed when the checkbox is off (gate
closed). Clear button explicitly drops the cell back to inheritance.
Project matrix surfaces inherited "no approval" state with its own
attribution chip ("Geerbt · keine Genehmigung") so admins can tell a
silently-inherited off-state from a never-authored cell.
PUT /api/.../approval-policies/{entity}/{lifecycle} accepts the new
shape `{requires_approval: bool, min_role: string|null}` while still
honouring the legacy `{required_role: "..."}` body during the M1
dual-read window (decodePolicyBody routes to UpsertProjectPolicySplit
vs UpsertProjectPolicy accordingly).
C+E — Pending-approval badge + Withdraw button:
deadlines-detail + appointments-detail surface a "Wartet auf
Genehmigung" badge when approval_status='pending'. Hover-tooltip
carries requested_at + required_role + requester_name. Action
controls (Complete, Edit, Delete) freeze while pending — caller
would get a 409 anyway, no point letting them try.
Withdraw button visible only to the requester (me.id ===
pending_request.requested_by). Click → POST /api/approval-requests/
{id}/revoke (existing endpoint, no new server route). On success,
the entity flips back to approval_status='approved' and the page
re-renders with normal controls.
Complete button now handles 409 from the server gracefully:
surfaces the new mapApprovalError body's `message` instead of
silently disabling itself.
D — /inbox "Meine Anfragen" visibility hardening:
Three defence-in-depth fixes for the "tab shows empty" report:
1. handlers force `[]` (not Go-nil → JSON null) on every inbox
endpoint so the frontend never trips on `rows.length` of null.
2. parseInboxFilter validates ?status= against an allowlist
(pending|approved|rejected|revoked|superseded). Anything else
is silently dropped — a stray ?status=foo from a stale
frontend build can no longer shadow rows out of the result.
entity_type filter same treatment (deadline|appointment).
3. Frontend inbox.ts coerces null body → [] so older / cached
builds talking to the new server still don't crash.
Test coverage: TestParseInboxFilter_DropsUnknownStatus +
TestApprovalService_ListSubmittedByUser_PendingVisible (live-DB,
skipped without TEST_DATABASE_URL).
Build clean: bun build OK, go test ./... OK.
Defers: M2 (drop required_role column) — only fires once all
in-tree writers are confirmed off the legacy column path.
641 lines
21 KiB
TypeScript
641 lines
21 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";
|
|
|
|
interface Deadline {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
description?: string;
|
|
due_date: string;
|
|
status: string;
|
|
source: string;
|
|
rule_id?: string;
|
|
rule_code?: 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;
|
|
}
|
|
|
|
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;
|
|
rule_code?: string;
|
|
}
|
|
|
|
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;
|
|
|
|
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 loadRule(ruleID: string) {
|
|
try {
|
|
const resp = await fetch(`/api/deadline-rules`);
|
|
if (!resp.ok) return;
|
|
const all: DeadlineRule[] = await resp.json();
|
|
rule = all.find((r) => r.id === ruleID) || null;
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
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")!;
|
|
if (rule) {
|
|
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).
|
|
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 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;
|
|
|
|
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;
|
|
}
|
|
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 = "";
|
|
}
|
|
saveBtn.style.display = "none";
|
|
editBtn.style.display = "";
|
|
}
|
|
|
|
editBtn.addEventListener("click", enterEdit);
|
|
|
|
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;
|
|
}
|
|
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. Reuses the existing
|
|
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
|
// needed). After the revoke lands, the entity goes back to
|
|
// approval_status='approved' and the page reloads to refresh the
|
|
// in-memory state cleanly.
|
|
function initWithdraw() {
|
|
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
btn.addEventListener("click", async () => {
|
|
if (!deadline || !pendingRequest) return;
|
|
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
|
btn.disabled = true;
|
|
try {
|
|
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.
|
|
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
|
if (r.ok) {
|
|
deadline = await r.json();
|
|
await loadPendingRequest();
|
|
render();
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
} 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()]);
|
|
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
|
|
// 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",
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|