- /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>
231 lines
10 KiB
JavaScript
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();
|
|
}
|