m's 2026-05-08 22:08 dogfood: my first auto-fill landed but kept Regel
and Typ as TWO separate input fields. m wanted ONE — "these two are
connected, it's the same thing".
Now: when a Regel is selected and the rule's concept resolves to a
canonical event_type via the jurisdiction-aware junction, the Typ
chip cluster is HIDDEN and replaced by an inline summary —
Klageerwiderung (vorgegeben durch Regel) Anderen Typ wählen
Clicking "Anderen Typ wählen" sets a sticky expandedOverride flag
that forces the picker visible for the rest of the form session.
The chip stays in the picker so the user can edit / remove it.
The picker also stays visible when the rule has no canonical
event_type (fallback to free-text Typ) or when the user has picked
a different event_type from the canonical default (mismatch
warning surfaces yellow next to the picker, never blocking).
DE+EN i18n: deadlines.field.rule.{autofill_inline,override}.
New CSS: .event-type-collapsed{,-label,-source,-override} reusing
the existing lime-tint chip palette.
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { initI18n, t, tDyn } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import {
|
|
attachEventTypePicker,
|
|
eventTypeLabel,
|
|
fetchEventTypes,
|
|
type EventType,
|
|
type PickerHandle,
|
|
} from "./event-types";
|
|
import { projectIndent } from "./project-indent";
|
|
|
|
let eventTypePicker: PickerHandle | null = null;
|
|
let currentUserAdmin = false;
|
|
let eventTypesByID = new Map<string, EventType>();
|
|
// expandedOverride flips to true when the user clicks "Anderen Typ
|
|
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
|
// form session — cleared only when the user reverts the rule to "Keine
|
|
// Regel". When true, the picker stays visible regardless of whether
|
|
// the chip matches the rule's canonical default.
|
|
let expandedOverride = false;
|
|
|
|
interface Project {
|
|
id: string;
|
|
reference?: string | null;
|
|
title: string;
|
|
path: string;
|
|
}
|
|
|
|
interface DeadlineRule {
|
|
id: string;
|
|
code?: string;
|
|
name: string;
|
|
name_en: string;
|
|
rule_code?: string;
|
|
// t-paliad-165 — canonical event_type for this rule's concept,
|
|
// hydrated server-side from paliad.deadline_concept_event_types.
|
|
// Drives auto-fill of the Typ chip when the user picks this rule.
|
|
concept_default_event_type_id?: string | null;
|
|
}
|
|
|
|
// Rules indexed by id so the Regel-change handler can look up the
|
|
// concept's canonical event_type without re-fetching.
|
|
let rulesByID = new Map<string, DeadlineRule>();
|
|
|
|
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
|
// the picker still reflects the rule's suggestion (replace silently on
|
|
// new rule pick) or whether the user has manually edited (leave alone,
|
|
// surface the mismatch warning instead).
|
|
let lastAutoFilledEventTypeID: string | null = null;
|
|
|
|
let preselectedProjectID = "";
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function showError(msg: string) {
|
|
const el = document.getElementById("deadline-new-msg")!;
|
|
el.textContent = msg;
|
|
el.className = "form-msg form-msg-error";
|
|
}
|
|
|
|
async function loadProjects() {
|
|
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
|
const hint = document.getElementById("deadline-project-empty-hint")!;
|
|
try {
|
|
const resp = await fetch("/api/projects");
|
|
if (!resp.ok) return;
|
|
const projects: Project[] = await resp.json();
|
|
if (projects.length === 0) {
|
|
hint.style.display = "";
|
|
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
|
return;
|
|
}
|
|
const options: string[] = [
|
|
`<option value="" disabled${preselectedProjectID ? "" : " selected"} data-i18n="deadlines.field.akte.choose">${esc(t("deadlines.field.akte.choose"))}</option>`,
|
|
];
|
|
for (const p of projects) {
|
|
const isSelected = preselectedProjectID === p.id ? " selected" : "";
|
|
const ref = p.reference || "";
|
|
const indent = projectIndent(p.path);
|
|
options.push(
|
|
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
|
);
|
|
}
|
|
sel.innerHTML = options.join("");
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
async function loadRules() {
|
|
// Optional: load rules so user can attach. We pull all rules; small set.
|
|
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
|
try {
|
|
const resp = await fetch("/api/deadline-rules");
|
|
if (!resp.ok) return;
|
|
const rules: DeadlineRule[] = await resp.json();
|
|
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
|
const opts: string[] = [
|
|
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
|
];
|
|
for (const r of rules) {
|
|
const code = r.rule_code || r.code || "";
|
|
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
|
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
|
}
|
|
sel.innerHTML = opts.join("");
|
|
} catch {
|
|
/* non-fatal — rule select stays at "no rule" */
|
|
}
|
|
}
|
|
|
|
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
|
// picker. The two modes are mutually exclusive:
|
|
//
|
|
// collapsed: rule selected + canonical event_type known + picker
|
|
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
|
// wählen". Hides the chip cluster, surfaces a single inline
|
|
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
|
// override link.
|
|
//
|
|
// expanded: every other case — no rule, no default for the rule,
|
|
// picker has been edited, or expandedOverride is sticky after the
|
|
// user clicked the override link. Picker visible; mismatch warning
|
|
// surfaces yellow when the rule expected a different event_type.
|
|
function refreshRuleView(): void {
|
|
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
|
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
|
const pickerHost = document.getElementById("deadline-event-types");
|
|
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
|
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
|
|
|
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
|
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
|
const expected = rule?.concept_default_event_type_id ?? null;
|
|
const picked = eventTypePicker?.getIDs() ?? [];
|
|
|
|
const pickerMatchesDefault =
|
|
expected !== null && picked.length === 1 && picked[0] === expected;
|
|
const wantsCollapsed =
|
|
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
|
|
|
if (wantsCollapsed) {
|
|
const et = eventTypesByID.get(expected!);
|
|
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
|
collapsed.style.display = "";
|
|
pickerHost.style.display = "none";
|
|
warn.style.display = "none";
|
|
return;
|
|
}
|
|
|
|
collapsed.style.display = "none";
|
|
pickerHost.style.display = "";
|
|
// Mismatch warning: rule expected an event_type AND the picker
|
|
// doesn't contain it. (When the picker is empty + no override, no
|
|
// warning — user is free to leave it blank.)
|
|
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
|
warn.style.display = "";
|
|
} else {
|
|
warn.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// applyRuleAutoFill replaces the picker silently when it still reflects
|
|
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
|
// picker alone. Called whenever the Regel select changes.
|
|
function applyRuleAutoFill(): void {
|
|
if (!eventTypePicker) return;
|
|
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
|
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
|
const expected = rule?.concept_default_event_type_id ?? null;
|
|
const current = eventTypePicker.getIDs();
|
|
|
|
// Reset the override on transition to "Keine Regel" — fresh form
|
|
// session. Otherwise expandedOverride stays sticky.
|
|
if (ruleID === "") {
|
|
expandedOverride = false;
|
|
}
|
|
|
|
const pickerStillReflectsLastSuggestion =
|
|
lastAutoFilledEventTypeID !== null &&
|
|
current.length === 1 &&
|
|
current[0] === lastAutoFilledEventTypeID;
|
|
const pickerIsEmpty = current.length === 0;
|
|
|
|
if (expected) {
|
|
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
|
eventTypePicker.setIDs([expected]);
|
|
lastAutoFilledEventTypeID = expected;
|
|
}
|
|
} else if (pickerStillReflectsLastSuggestion) {
|
|
// New rule has no canonical event_type — clear the stale auto-fill
|
|
// so the picker doesn't carry a chip from the old rule.
|
|
eventTypePicker.setIDs([]);
|
|
lastAutoFilledEventTypeID = null;
|
|
}
|
|
refreshRuleView();
|
|
}
|
|
|
|
function initBackLinks() {
|
|
if (preselectedProjectID) {
|
|
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
|
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
|
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
|
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
|
}
|
|
}
|
|
|
|
async function submitForm(e: Event) {
|
|
e.preventDefault();
|
|
const submitBtn = document.querySelector<HTMLButtonElement>("#deadline-new-form button[type=submit]")!;
|
|
const msg = document.getElementById("deadline-new-msg")!;
|
|
|
|
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
|
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
|
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
|
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
|
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
|
|
|
if (!projectID || !title || !due) {
|
|
showError(t("deadlines.error.required"));
|
|
return;
|
|
}
|
|
|
|
msg.textContent = "";
|
|
msg.className = "form-msg";
|
|
submitBtn.disabled = true;
|
|
|
|
const payload: Record<string, unknown> = {
|
|
title,
|
|
due_date: due,
|
|
source: "manual",
|
|
};
|
|
if (ruleID) payload.rule_id = ruleID;
|
|
if (notes) payload.notes = notes;
|
|
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
|
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
|
|
|
try {
|
|
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
const data = await resp.json().catch(() => ({}) as { error?: string });
|
|
showError(data.error || t("deadlines.error.generic"));
|
|
submitBtn.disabled = false;
|
|
return;
|
|
}
|
|
const created = await resp.json();
|
|
if (preselectedProjectID) {
|
|
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
|
} else {
|
|
window.location.href = `/deadlines/${created.id}`;
|
|
}
|
|
} catch {
|
|
showError(t("deadlines.error.generic"));
|
|
submitBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function detectPreselect() {
|
|
// Path /projects/{id}/deadlines/new pre-selects that project.
|
|
const parts = window.location.pathname.split("/").filter(Boolean);
|
|
if (parts[0] === "projects" && parts[1] && parts[2] === "deadlines" && parts[3] === "new") {
|
|
preselectedProjectID = parts[1];
|
|
}
|
|
// Or ?project_id= query string
|
|
const qp = new URLSearchParams(window.location.search);
|
|
const fromQuery = qp.get("project_id");
|
|
if (fromQuery) preselectedProjectID = fromQuery;
|
|
}
|
|
|
|
async function loadMe() {
|
|
try {
|
|
const resp = await fetch("/api/me");
|
|
if (!resp.ok) return;
|
|
const me = await resp.json();
|
|
currentUserAdmin = me?.global_role === "global_admin";
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
// t-paliad-154 — fetch the effective approval policy for (project,
|
|
// deadline, create) and reveal the form-time hint when it applies.
|
|
// Hidden when no policy applies. Re-runs on project change so the hint
|
|
// updates if the user picks a different project mid-form.
|
|
async function refreshApprovalHint(): Promise<void> {
|
|
const hint = document.getElementById("deadline-approval-hint");
|
|
const text = document.getElementById("deadline-approval-hint-text");
|
|
if (!hint || !text) return;
|
|
const projectID = (document.getElementById("deadline-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=deadline&lifecycle=create`,
|
|
{ credentials: "include" },
|
|
);
|
|
if (!resp.ok) {
|
|
hint.style.display = "none";
|
|
return;
|
|
}
|
|
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
|
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("deadlines.form.approval_hint") || "4-Augen-Prüfung erforderlich")
|
|
+ ` · ${roleLabel}${sourceLabel}`;
|
|
hint.style.display = "";
|
|
} catch {
|
|
hint.style.display = "none";
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
initI18n();
|
|
initSidebar();
|
|
detectPreselect();
|
|
initBackLinks();
|
|
document.getElementById("deadline-new-form")!.addEventListener("submit", submitForm);
|
|
// Default due to today
|
|
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
|
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
|
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
|
const pickerHost = document.getElementById("deadline-event-types");
|
|
if (pickerHost) {
|
|
eventTypePicker = attachEventTypePicker(pickerHost, {
|
|
currentUserAdmin,
|
|
onChange: () => refreshRuleView(),
|
|
});
|
|
}
|
|
// t-paliad-165 follow-up — preload event_types so the collapsed
|
|
// summary can render the type's label inline without an extra round
|
|
// trip when the user picks a Regel.
|
|
fetchEventTypes()
|
|
.then((types) => {
|
|
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
|
refreshRuleView();
|
|
})
|
|
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
|
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
|
// concept's canonical event_type, when the picker hasn't been
|
|
// manually edited away from the previous rule's suggestion.
|
|
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
|
applyRuleAutoFill();
|
|
});
|
|
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
|
// visible even when the chip still matches the rule's default.
|
|
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
|
expandedOverride = true;
|
|
refreshRuleView();
|
|
// Move focus into the picker's search box so the user can type
|
|
// immediately without an extra click.
|
|
const search = document.querySelector<HTMLInputElement>(
|
|
"#deadline-event-types .event-type-search",
|
|
);
|
|
search?.focus();
|
|
});
|
|
// Wire approval-hint refresh: on first render + on project change.
|
|
void refreshApprovalHint();
|
|
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
|
void refreshApprovalHint();
|
|
});
|
|
});
|