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