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.
394 lines
14 KiB
TypeScript
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();
|
|
});
|