Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/cases.js
T
RichardandClaude Opus 5.5 418a73015d Vorgaenge in der WebUI: entscheiden und Stellungnahme anfordern (#689, Stufe 3)
- /api/cases (offen/alle), /api/cases/{id}/decide, /api/cases/{id}/statement, /api/decisions
- Ansicht "Vorgaenge": offene Faelle mit Verlauf je Ebene (Lauf-Links), Frist und
  "Mensch gerufen"; Reiter Beschluesse mit Reichweite; Zaehler in der Seitenleiste
- Konzept Abschnitt 5: Umsetzung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:53:25 +02:00

231 lines
10 KiB
JavaScript

// 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();
}