Files
paliad/frontend/src/client/appointments-detail.ts
mAi 72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00

467 lines
17 KiB
TypeScript

import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
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;
// 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;
}
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;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
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.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = freeze; });
}
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 {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
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";
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → 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) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
} 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();
});