// ClawdDotNet — cases: escalations, conflicts and the decisions that close them (#689) // // A case nobody solves in time comes to a human. Here the human reads what each level said — // appended, never replaced (#697) — decides with reasons, or asks one side for a statement. // The decisions tab is the organisation's memory: what was decided, why, and how far it binds. // // Everything in a trail was written by agents and may carry foreign text. It is only ever // inserted via textContent. import { api } from "../modules/api.js"; import { t } from "../modules/i18n.js"; import { erstelleElement, erstelleIcon, PFADE, erstellePlakette, erstelleKarte, erstelleModal, formatiereDatumZeit, zeigeMeldung } from "../modules/dom.js"; import { laufLink } from "./run-detail.js"; let reiter = "open"; /** Open cases a human holds — for the badge in the sidebar. */ export async function holeMenschenfaelle() { try { const liste = await api("/cases"); return Array.isArray(liste) ? liste.filter((c) => !c.holder).length : 0; } catch { return 0; } } export async function renderCases() { const container = document.getElementById("ansicht-inhalt"); if (!container) return; const kopf = erstelleElement("div", { klasse: "ansicht-kopf" }, erstelleElement("h1", { text: t("cases.title") }), erstelleElement("button", { klasse: "schaltflaeche flach", onclick: laden }, erstelleIcon(PFADE.aktualisieren, 16), erstelleElement("span", { text: ` ${t("cases.refresh")}` }))); const leiste = erstelleElement("div", { klasse: "reiter-leiste" }); for (const [wert, schluessel] of [["open", "cases.tabOpen"], ["all", "cases.tabAll"], ["decisions", "cases.tabDecisions"]]) { leiste.append(erstelleElement("button", { klasse: `reiter-schaltflaeche${wert === reiter ? " aktiv" : ""}`, text: t(schluessel), dataset: { wert }, onclick: () => { reiter = wert; for (const b of leiste.children) b.classList.toggle("aktiv", b.dataset.wert === wert); laden(); } })); } const hinweis = erstelleElement("p", { klasse: "gedaempft", text: t("cases.intro") }); const bereich = erstelleElement("div", { id: "faelle-bereich", klasse: "faelle-liste" }); container.replaceChildren(erstelleElement("div", { klasse: "faelle-ansicht" }, kopf, hinweis, leiste, bereich)); await laden(); } async function laden() { const bereich = document.getElementById("faelle-bereich"); if (!bereich) return; bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") })); try { if (reiter === "decisions") { const liste = await api("/decisions"); bereich.replaceChildren(...(liste.length === 0 ? [leer(t("cases.noDecisions"), t("cases.noDecisionsHint"))] : liste.map(beschlussKarte))); return; } const liste = await api(reiter === "all" ? "/cases?all=true" : "/cases"); // What waits for a human first, then the most urgent deadline. liste.sort((a, b) => (a.holder ? 1 : 0) - (b.holder ? 1 : 0) || new Date(a.deadlineUtc) - new Date(b.deadlineUtc)); bereich.replaceChildren(...(liste.length === 0 ? [leer(t("cases.none"), t("cases.noneHint"))] : liste.map(fallKarte))); } catch (err) { bereich.replaceChildren(leer(t("cases.loadFailed"), err.message)); } } function leer(titel, text) { return erstelleKarte(titel, [erstelleElement("p", { klasse: "gedaempft", text })]); } function fallKarte(fall) { const offen = fall.status === "open"; const beimMenschen = offen && !fall.holder; const ueberfaellig = offen && new Date(fall.deadlineUtc) < new Date(); const plaketten = erstelleElement("div", { klasse: "gruppe-horizontal fall-plaketten" }, erstellePlakette(t(fall.kind === "conflict" ? "cases.kindConflict" : "cases.kindEscalation"), fall.kind === "conflict" ? "warnung" : "neutral"), erstellePlakette(t(`cases.status.${fall.status}`), offen ? (beimMenschen ? "gefahr" : "neutral") : "erfolg")); if (ueberfaellig && !beimMenschen) plaketten.append(erstellePlakette(t("cases.overdue"), "gefahr")); if (fall.humanCalledAt) plaketten.append(erstellePlakette(t("cases.called"), "warnung")); const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" }); if (beimMenschen) { aktionen.append( erstelleElement("button", { klasse: "schaltflaeche primaer klein", onclick: () => entscheiden(fall) }, erstelleIcon(PFADE.haken, 14), erstelleElement("span", { text: ` ${t("cases.decide")}` })), erstelleElement("button", { klasse: "schaltflaeche flach klein", onclick: () => stellungnahme(fall) }, erstelleElement("span", { text: t("cases.requestStatement") }))); } const daten = erstelleElement("dl", { klasse: "datenliste" }); const zeile = (bez, wert) => daten.append( erstelleElement("dt", { text: t(bez) }), wert instanceof Node ? erstelleElement("dd", {}, wert) : erstelleElement("dd", { text: wert ?? "—" })); zeile("cases.id", fall.id); zeile("cases.openedBy", `${fall.openedBy} · ${formatiereDatumZeit(fall.openedAt)}`); zeile("cases.holder", fall.holder || t("cases.human")); zeile("cases.deadline", formatiereDatumZeit(fall.deadlineUtc)); if (fall.level > 0) zeile("cases.level", String(fall.level)); if (fall.parties.length > 0) zeile("cases.parties", fall.parties.join(" · ")); if (fall.informed.length > 0) zeile("cases.informed", fall.informed.join(", ")); if (fall.waitingFor.length > 0) zeile("cases.waitingFor", fall.waitingFor.join(", ")); if (fall.originTaskId || fall.originRunId) { const herkunft = erstelleElement("span", { text: fall.originTaskId ? `${fall.originTaskId} ` : "" }); if (fall.originRunId) herkunft.append(laufLink(fall.originRunId, t("tasks.originRun"))); zeile("cases.origin", herkunft); } const verlauf = erstelleElement("ol", { klasse: "fall-verlauf" }); for (const eintrag of fall.trail) { const kopfzeile = erstelleElement("div", { klasse: "fall-eintrag-kopf gedaempft text-klein" }, `${formatiereDatumZeit(eintrag.at)} · ${eintrag.author === "human" ? t("cases.humanAuthor") : eintrag.author} · ${t(`cases.entry.${eintrag.kind}`)}`); if (eintrag.runId) kopfzeile.append(" · ", laufLink(eintrag.runId, t("tasks.originRun"))); verlauf.append(erstelleElement("li", { klasse: `fall-eintrag fall-eintrag-${eintrag.kind}` }, kopfzeile, erstelleElement("div", { klasse: "fall-eintrag-text", text: eintrag.text }))); } return erstelleKarte(fall.subject, [ plaketten, daten, erstelleElement("h3", { klasse: "fall-verlauf-titel", text: t("cases.trail") }), verlauf ], { aktionen, klasse: beimMenschen ? "fall-karte fall-beim-menschen" : "fall-karte" }); } function beschlussKarte(beschluss) { const daten = erstelleElement("dl", { klasse: "datenliste" }); const zeile = (bez, wert) => daten.append(erstelleElement("dt", { text: t(bez) }), erstelleElement("dd", { text: wert ?? "—" })); zeile("cases.answer", beschluss.answer); zeile("cases.reasoning", beschluss.reasoning); zeile("cases.scope", beschluss.scopePath || t("cases.scopeAll")); zeile("cases.decidedBy", `${beschluss.decidedBy === "human" ? t("cases.humanAuthor") : beschluss.decidedBy} · ${formatiereDatumZeit(beschluss.decidedAt)}`); zeile("cases.id", `${beschluss.id} · ${beschluss.caseId}`); return erstelleKarte(beschluss.subject, [daten], { klasse: "fall-karte" }); } // ─── the human's two answers ─── function textfeld(platzhalter) { return erstelleElement("textarea", { rows: 4, required: true, placeholder: platzhalter }); } function entscheiden(fall) { const antwort = textfeld(t("cases.answerPlaceholder")); const begruendung = textfeld(t("cases.reasoningPlaceholder")); const formular = erstelleElement("form", { klasse: "formular-raster" }, erstelleElement("label", { text: t("cases.answer") }), antwort, erstelleElement("label", { text: t("cases.reasoning") }), begruendung, erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.decideHint") })); absenden(t("cases.decideTitle", { id: fall.id }), formular, t("cases.decide"), async () => { await api(`/cases/${encodeURIComponent(fall.id)}/decide`, { method: "POST", body: { answer: antwort.value.trim(), reasoning: begruendung.value.trim() } }); zeigeMeldung(t("cases.decided"), "info"); }); } async function stellungnahme(fall) { const auswahl = erstelleElement("select", { required: true }); let agenten = []; try { agenten = await api("/agents"); } catch { /* the list stays empty; the server refuses an unknown agent */ } // The sides and the levels in between first — they are who a statement is usually asked of. const nahe = new Set([...fall.parties, ...fall.informed, fall.openedBy]); agenten.sort((a, b) => (nahe.has(b.agentId) ? 1 : 0) - (nahe.has(a.agentId) ? 1 : 0) || a.agentId.localeCompare(b.agentId)); for (const a of agenten) auswahl.append(erstelleElement("option", { value: a.agentId, text: a.displayName && a.displayName !== a.agentId ? `${a.agentId} (${a.displayName})` : a.agentId })); const frage = textfeld(t("cases.questionPlaceholder")); const formular = erstelleElement("form", { klasse: "formular-raster" }, erstelleElement("label", { text: t("cases.statementFrom") }), auswahl, erstelleElement("label", { text: t("cases.question") }), frage, erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.statementHint") })); absenden(t("cases.statementTitle", { id: fall.id }), formular, t("cases.requestStatement"), async () => { await api(`/cases/${encodeURIComponent(fall.id)}/statement`, { method: "POST", body: { agentId: auswahl.value, question: frage.value.trim() } }); zeigeMeldung(t("cases.statementRequested"), "info"); }); } function absenden(titel, formular, knopfText, aktion) { const abbrechen = erstelleElement("button", { type: "button", klasse: "schaltflaeche flach", text: t("common.cancel"), onclick: () => modal.schliessen() }); const senden = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: knopfText }); const modal = erstelleModal(titel, formular, [abbrechen, senden]); formular.addEventListener("submit", async (e) => { e.preventDefault(); senden.disabled = true; try { await aktion(); modal.schliessen(); await laden(); } catch (err) { zeigeMeldung(t("cases.failed", { error: err.message }), "gefahr"); } finally { senden.disabled = false; } }); modal.oeffnen(); }