Files
paliad/frontend/src/client/appointments-detail.ts
m 073af975f7 feat(approvals/t-paliad-160 slice2): admin UI flip + badge + withdraw + inbox visibility hardening
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.
2026-05-08 17:07:46 +02:00

394 lines
14 KiB
TypeScript

import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
interface Appointment {
id: string;
project_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
created_by?: string;
// t-paliad-138 + t-paliad-160 — pending-approval surface.
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;
}
interface Me {
id: string;
}
interface Project {
id: string;
reference?: string | null;
title: string;
path?: string;
}
let appointment: Appointment | null = null;
let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "appointments" || !parts[1]) return null;
return parts[1];
}
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 toLocalInput(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function loadAppointment(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/appointments/${id}`);
if (!resp.ok) return false;
appointment = await resp.json();
return true;
} catch {
return false;
}
}
async function loadProject(id: string) {
try {
const resp = await fetch(`/api/projects/${id}`);
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 */
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
// loadPendingRequest mirrors deadlines-detail.ts (t-paliad-160 §C+E):
// pull the in-flight approval_request when the entity is pending so the
// badge tooltip + the Withdraw button can be wired correctly.
async function loadPendingRequest(): Promise<void> {
pendingRequest = null;
if (!appointment || appointment.approval_status !== "pending" || !appointment.pending_request_id) {
return;
}
try {
const resp = await fetch(`/api/approval-requests/${appointment.pending_request_id}`);
if (resp.ok) pendingRequest = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjectPicker() {
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (!sel) return;
const none = sel.querySelector('option[value=""]');
sel.innerHTML = "";
if (none) sel.appendChild(none);
for (const p of allProjects) {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = `${projectIndent(p.path)}${p.reference || ""}${p.title}`;
sel.appendChild(opt);
}
if (appointment) {
sel.value = appointment.project_id ?? "";
}
}
function renderHeader() {
if (!appointment) return;
document.getElementById("appointment-title-display")!.textContent = appointment.title;
const time = appointment.end_at
? `${fmtDateTime(appointment.start_at)}${fmtDateTime(appointment.end_at)}`
: fmtDateTime(appointment.start_at);
document.getElementById("appointment-time-display")!.textContent = time;
const badge = document.getElementById("appointment-type-badge")!;
if (appointment.appointment_type) {
badge.textContent = tDyn(`appointments.type.${appointment.appointment_type}`) || appointment.appointment_type;
badge.className = `termin-type-badge termin-type-${appointment.appointment_type}`;
badge.style.display = "";
} else {
badge.style.display = "none";
}
const projectRow = document.getElementById("appointment-project-row")!;
if (appointment.project_id && project) {
const link = document.getElementById("appointment-project-link") as HTMLAnchorElement;
link.href = `/projects/${project.id}`;
link.textContent = `${project.reference || ""}${project.title}`;
projectRow.style.display = "";
} else {
projectRow.style.display = "none";
}
// t-paliad-160 §C+E — pending-approval badge + withdraw + freeze controls.
const isPending = appointment.approval_status === "pending";
const isRequester = !!(me && pendingRequest && me.id === pendingRequest.requested_by);
const apBadge = document.getElementById("appointment-pending-approval-badge") as HTMLElement | null;
if (apBadge) {
if (isPending) {
apBadge.style.display = "";
const labelDe = t("approvals.pending.badge") || "Wartet auf Genehmigung";
apBadge.textContent = labelDe;
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);
apBadge.title = `${labelDe} · ${role}+ · ${requester} · ${when}`;
} else {
apBadge.title = labelDe;
}
} else {
apBadge.style.display = "none";
apBadge.title = "";
}
}
const withdrawBtn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (withdrawBtn) {
withdrawBtn.style.display = (isPending && isRequester) ? "" : "none";
withdrawBtn.disabled = false;
}
// Freeze the edit form + delete button while a request is in flight.
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
}
function fillEditForm() {
if (!appointment) return;
(document.getElementById("appointment-title-edit") as HTMLInputElement).value = appointment.title;
(document.getElementById("appointment-start-edit") as HTMLInputElement).value = toLocalInput(appointment.start_at);
(document.getElementById("appointment-end-edit") as HTMLInputElement).value = toLocalInput(appointment.end_at);
(document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? "";
(document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? "";
(document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? "";
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
if (projectSel) projectSel.value = appointment.project_id ?? "";
}
async function saveEdit(ev: Event) {
ev.preventDefault();
if (!appointment) return;
const msg = document.getElementById("appointment-edit-msg")!;
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
msg.textContent = "";
const title = (document.getElementById("appointment-title-edit") as HTMLInputElement).value.trim();
const startRaw = (document.getElementById("appointment-start-edit") as HTMLInputElement).value;
const endRaw = (document.getElementById("appointment-end-edit") as HTMLInputElement).value;
const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value;
const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim();
const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value;
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
const newProjectID = projectSel ? projectSel.value : "";
const currentProjectID = appointment.project_id ?? "";
const payload: Record<string, unknown> = {
title,
start_at: new Date(startRaw).toISOString(),
end_at: endRaw ? new Date(endRaw).toISOString() : null,
appointment_type: type,
location,
description,
};
if (newProjectID !== currentProjectID) {
if (newProjectID === "") {
payload.clear_project = true;
} else {
payload.project_id = newProjectID;
}
}
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const prevProjectID = appointment.project_id ?? "";
appointment = await resp.json();
const nextProjectID = appointment?.project_id ?? "";
if (nextProjectID !== prevProjectID) {
project = null;
if (appointment?.project_id) await loadProject(appointment.project_id);
}
renderHeader();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
}
async function deleteAppointment() {
if (!appointment) return;
if (!confirm(t("appointments.detail.delete.confirm"))) return;
try {
const resp = await fetch(`/api/appointments/${appointment.id}`, { method: "DELETE" });
if (resp.ok || resp.status === 204) {
window.location.href = "/events?type=appointment";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
}
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) 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) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = data.message || data.error || (t("approvals.withdraw.error") || "Fehler beim Zurückziehen");
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
} catch (e) {
const msg = document.getElementById("appointment-edit-msg")!;
msg.textContent = (t("approvals.withdraw.error") || "Fehler beim Zurückziehen") + ": " + e;
msg.className = "form-msg form-msg-error";
if (btn) btn.disabled = false;
}
}
async function main() {
const id = parseAppointmentID();
const loading = document.getElementById("appointment-loading")!;
const body = document.getElementById("appointment-body")!;
const notFound = document.getElementById("appointment-not-found")!;
if (!id) {
loading.style.display = "none";
notFound.style.display = "block";
return;
}
const ok = await loadAppointment(id);
if (!ok || !appointment) {
loading.style.display = "none";
notFound.style.display = "block";
return;
}
await Promise.all([
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
loadAllProjects(),
loadMe(),
loadPendingRequest(),
]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
populateProjectPicker();
fillEditForm();
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
const withdrawBtn = document.getElementById("appointment-withdraw-btn");
if (withdrawBtn) withdrawBtn.addEventListener("click", () => void withdrawAppointmentRequest());
const notes = document.getElementById("notes-container");
if (notes) {
notes.setAttribute("data-parent-id", id);
void initNotes(notes as HTMLElement, "appointment", id);
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
main();
});