Files
paliad/frontend/src/client/appointments-new.ts
m aec6cf6104 refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
Cleanup of the t-paliad-160 dual-read shim. After slice 1+2 every writer
hits both `required_role` and the new `(requires_approval, min_role)`
columns, and every reader prefers the new ones. M2 (migration 065) drops
the legacy column from `paliad.approval_policies` and rewrites
`paliad.approval_policy_effective()` to a 4-column return shape.

`paliad.approval_requests.required_role` is intentionally untouched —
that's the in-flight policy snapshot at submission time, a separate
concern from the policy authoring grammar.

Go side:
  - models.ApprovalPolicy and models.EffectivePolicy lose RequiredRole.
    The MinRole pointer is now the only seniority-threshold surface.
  - LookupPolicy / GetEffectivePolicyOne / List* / snapshotProjectRows
    drop the required_role SELECT projection.
  - UpsertProjectPolicySplit / UpsertUnitPolicySplit /
    DeleteProjectPolicy / DeleteUnitPolicy / ApplyMatrixToDescendants
    drop the required_role write. The audit-log row still uses the
    legacy string format ('partner|...|none'); composed via
    legacyFromSplit() from the new columns so the audit table layout
    keeps working without a parallel migration.
  - submit() reads policy.MinRole directly (LookupPolicy guarantees
    non-nil when a non-nil policy is returned).
  - nullToPtr helper retired (no remaining callers).

Frontend side:
  - admin-approval-policies.ts UnitPolicy / EffectivePolicy lose the
    legacy required_role optional. The 2-control UI was already on the
    split-grammar path.
  - deadlines-new.ts + appointments-new.ts form-time hint readers prefer
    requires_approval+min_role. They keep a soft-fall back to the
    legacy required_role for one cycle in case any cached pre-M2 server
    is still serving the old shape — that path is dead-code post-deploy
    and can be dropped later.

Test:
  - TestApprovalService_PolicyCRUD asserts MinRole instead of
    RequiredRole after re-upsert.

Build: bun build OK, go build ./... OK, go test ./... OK.

Deploy ordering: this slice MUST land after slice 2 is merged so the
pre-deploy code paths that still reference required_role have already
been retired.
2026-05-08 17:15:05 +02:00

169 lines
5.9 KiB
TypeScript

import { initI18n, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
}
let allProjects: Project[] = [];
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function loadProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
function populateProjects() {
const sel = document.getElementById("appointment-project") as HTMLSelectElement;
const opts: string[] = [
`<option value="">${esc(t("appointments.field.akte.none"))}</option>`,
];
for (const a of allProjects) {
const indent = projectIndent(a.path);
opts.push(
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = opts.join("");
const params = new URLSearchParams(window.location.search);
const ak = params.get("project_id");
if (ak) sel.value = ak;
}
function preFillStart() {
const start = document.getElementById("appointment-start") as HTMLInputElement;
const now = new Date();
now.setMinutes(now.getMinutes() + (15 - (now.getMinutes() % 15)));
now.setSeconds(0);
now.setMilliseconds(0);
const pad = (n: number) => String(n).padStart(2, "0");
start.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
async function submitForm(ev: Event) {
ev.preventDefault();
const msg = document.getElementById("appointment-new-msg")!;
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
msg.textContent = "";
const title = (document.getElementById("appointment-title") as HTMLInputElement).value.trim();
const startRaw = (document.getElementById("appointment-start") as HTMLInputElement).value;
const endRaw = (document.getElementById("appointment-end") as HTMLInputElement).value;
const type = (document.getElementById("appointment-type") as HTMLSelectElement).value;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement).value;
const location = (document.getElementById("appointment-location") as HTMLInputElement).value.trim();
const description = (document.getElementById("appointment-description") as HTMLTextAreaElement).value.trim();
if (!title || !startRaw) {
msg.textContent = t("appointments.error.required");
msg.className = "form-msg form-msg-error";
return;
}
const payload: Record<string, unknown> = {
title,
start_at: new Date(startRaw).toISOString(),
};
if (endRaw) payload.end_at = new Date(endRaw).toISOString();
if (type) payload.appointment_type = type;
if (projectID) payload.project_id = projectID;
if (location) payload.location = location;
if (description) payload.description = description;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/appointments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
const created = await resp.json();
window.location.href = `/appointments/${created.id}`;
return;
}
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;
}
}
// t-paliad-154 — form-time 4-eye hint, mirroring deadlines-new.ts.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("appointment-approval-hint");
const text = document.getElementById("appointment-approval-hint-text");
if (!hint || !text) return;
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement | null)?.value || "";
if (!projectID) {
hint.style.display = "none";
return;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(projectID)}/approval-policies/effective?entity_type=appointment&lifecycle=create`,
{ credentials: "include" },
);
if (!resp.ok) {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar — read requires_approval + min_role.
// Fall back to the legacy required_role mirror (M1 dual-read window
// only — drops in M2).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
required_role?: string | null;
source?: string | null;
source_name?: string | null;
};
const role = eff.min_role || eff.required_role || null;
const required = (eff.requires_approval === true) || (role !== null && role !== "none");
if (!required || !role) {
hint.style.display = "none";
return;
}
const roleLabel = tDyn("admin.approval_policies.role." + role) || role;
const sourceLabel = eff.source_name
? ` · ${tDyn("admin.approval_policies.source." + (eff.source || "")) || ""}: ${eff.source_name}`
: "";
text.textContent = (t("appointments.form.approval_hint") || "4-Augen-Prüfung erforderlich")
+ ` · ${roleLabel}${sourceLabel}`;
hint.style.display = "";
} catch {
hint.style.display = "none";
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
await loadProjects();
populateProjects();
preFillStart();
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
void refreshApprovalHint();
document.getElementById("appointment-project")?.addEventListener("change", () => {
void refreshApprovalHint();
});
});