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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
36fe1ad2aa
commit
418a73015d
@@ -0,0 +1,230 @@
|
||||
// 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();
|
||||
}
|
||||
Reference in New Issue
Block a user