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