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
@@ -61,6 +61,12 @@ public sealed class AppHost : IAsyncDisposable
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>#689: Escalations, conflicts and decisions — read by the interfaces, acted on through the desk.</summary>
|
||||
public Core.Cases.ICaseRepository? Cases { get; init; }
|
||||
|
||||
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
|
||||
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
|
||||
|
||||
/// <summary>
|
||||
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
|
||||
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
|
||||
@@ -600,6 +606,7 @@ public sealed class AppHost : IAsyncDisposable
|
||||
Board = board,
|
||||
Usage = usage,
|
||||
Transcripts = transcripts,
|
||||
Cases = cases,
|
||||
Insights = new RunInsights(audit, transcripts, board, staging,
|
||||
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
|
||||
? engine.TranscriptRetentionFor(agent)
|
||||
|
||||
@@ -781,6 +781,68 @@ button.klein {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ─── #689: cases ─── */
|
||||
|
||||
.faelle-liste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.fall-plaketten {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* The two answers of the human wrap below the subject on a narrow screen instead of squeezing it. */
|
||||
.fall-karte .karte-kopf {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fall-karte .karte-kopf h2 {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.fall-beim-menschen {
|
||||
border-left: 3px solid var(--warnung);
|
||||
}
|
||||
|
||||
.fall-verlauf-titel {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fall-verlauf {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fall-eintrag {
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--divider);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.fall-eintrag-decided,
|
||||
.fall-eintrag-handled {
|
||||
border-left-color: var(--akzent, var(--divider));
|
||||
}
|
||||
|
||||
.fall-eintrag-called {
|
||||
border-left-color: var(--warnung);
|
||||
}
|
||||
|
||||
.fall-eintrag-text {
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* #715: marks a tool that brings outside text in — it changes the agent's reporting line. */
|
||||
.werkzeug-fremd {
|
||||
display: inline-block;
|
||||
|
||||
@@ -37,6 +37,7 @@ import { renderRunDetail } from "./views/run-detail.js";
|
||||
import { renderPromptHistory } from "./views/prompt-history.js";
|
||||
import { renderTeam, leaveTeam } from "./views/team.js";
|
||||
import { renderOrganisation } from "./views/organisation.js";
|
||||
import { renderCases, holeMenschenfaelle } from "./views/cases.js";
|
||||
|
||||
// ─── 1. Routen registrieren ───
|
||||
|
||||
@@ -57,6 +58,7 @@ registriereRoute("/team", () => { verlasseAnsicht(); return renderTeam(); });
|
||||
registriereRoute("/organisation", () => { verlasseAnsicht(); return renderOrganisation(); });
|
||||
registriereRoute("/dienste", () => { verlasseAnsicht(); return rendereDienste(); });
|
||||
registriereRoute("/staging", () => { verlasseAnsicht(); return rendereStaging(); });
|
||||
registriereRoute("/cases", () => { verlasseAnsicht(); return renderCases(); });
|
||||
registriereRoute("/verbrauch", () => { verlasseAnsicht(); return rendereVerbrauch(); });
|
||||
// #660: token usage sub-views and the prompt history
|
||||
registriereRoute("/verbrauch/runs", () => { verlasseAnsicht(); return renderUsageRuns(); });
|
||||
@@ -335,9 +337,19 @@ async function aktualisiereStagingZaehler() {
|
||||
if (!holeAktiveInstanz()) {
|
||||
const badge = document.getElementById("staging-badge");
|
||||
if (badge) badge.hidden = true;
|
||||
const faelle = document.getElementById("faelle-badge");
|
||||
if (faelle) faelle.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// #689: Cases that wait for a human are counted beside the approvals.
|
||||
const faelleAnzahl = await holeMenschenfaelle();
|
||||
const faelleBadge = document.getElementById("faelle-badge");
|
||||
if (faelleBadge) {
|
||||
faelleBadge.textContent = String(faelleAnzahl);
|
||||
faelleBadge.hidden = faelleAnzahl === 0;
|
||||
}
|
||||
|
||||
const anzahl = await holeWartendeAnzahl();
|
||||
const badge = document.getElementById("staging-badge");
|
||||
if (badge) {
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"nav.group.system": "System",
|
||||
"nav.instances": "Instanzen",
|
||||
"nav.staging": "Freigaben",
|
||||
"nav.cases": "Vorgänge",
|
||||
"nav.logs": "Protokoll",
|
||||
"nav.backup": "Sicherung",
|
||||
"nav.settings": "Einstellungen",
|
||||
@@ -851,5 +852,65 @@
|
||||
"desktop.dialog.addService": "Dienst hinzufügen",
|
||||
"desktop.dialog.restoreBackup": "Sicherung wiederherstellen",
|
||||
"desktop.dialog.license": "Lizenz-Aktivierung",
|
||||
"desktop.dialog.instancePicker": "Instanz auswählen"
|
||||
"desktop.dialog.instancePicker": "Instanz auswählen",
|
||||
|
||||
"cases.title": "Vorgänge",
|
||||
"cases.refresh": "Aktualisieren",
|
||||
"cases.intro": "Eskalationen und Konflikte, die die Agenten nicht unter sich lösen konnten, und die Beschlüsse, die sie geschlossen haben. Was bis zur Frist niemand löst, landet hier.",
|
||||
"cases.tabOpen": "Offen",
|
||||
"cases.tabAll": "Alle",
|
||||
"cases.tabDecisions": "Beschlüsse",
|
||||
"cases.none": "Keine offenen Vorgänge",
|
||||
"cases.noneHint": "Nichts wartet auf eine Entscheidung.",
|
||||
"cases.noDecisions": "Noch keine Beschlüsse",
|
||||
"cases.noDecisionsHint": "Jeder entschiedene Vorgang hinterlässt hier einen Beschluss. Er gilt für den Bereich, für den er gefasst wurde, und wird gezeigt, bevor jemand dieselbe Frage erneut stellt.",
|
||||
"cases.loadFailed": "Vorgänge konnten nicht geladen werden",
|
||||
"cases.kindEscalation": "Eskalation",
|
||||
"cases.kindConflict": "Konflikt",
|
||||
"cases.status.open": "offen",
|
||||
"cases.status.decided": "entschieden",
|
||||
"cases.status.handled": "erledigt",
|
||||
"cases.overdue": "Frist überschritten",
|
||||
"cases.called": "Mensch gerufen",
|
||||
"cases.id": "ID",
|
||||
"cases.openedBy": "Eröffnet von",
|
||||
"cases.holder": "Liegt bei",
|
||||
"cases.human": "dir (Mensch)",
|
||||
"cases.humanAuthor": "Mensch",
|
||||
"cases.deadline": "Frist",
|
||||
"cases.level": "Weitergereicht",
|
||||
"cases.parties": "Seiten",
|
||||
"cases.informed": "Vermerkt, nicht befragt",
|
||||
"cases.waitingFor": "Wartet auf",
|
||||
"cases.origin": "Herkunft",
|
||||
"cases.trail": "Was jede Ebene gesagt hat",
|
||||
"cases.entry.opened": "eröffnet",
|
||||
"cases.entry.joined": "angeschlossen",
|
||||
"cases.entry.forwarded": "weitergereicht",
|
||||
"cases.entry.position": "Standpunkt",
|
||||
"cases.entry.statement": "Stellungnahme",
|
||||
"cases.entry.statement-requested": "Stellungnahme angefordert",
|
||||
"cases.entry.called": "Mensch gerufen",
|
||||
"cases.entry.decided": "entschieden",
|
||||
"cases.entry.handled": "erledigt",
|
||||
"cases.entry.note": "Vermerk",
|
||||
"cases.decide": "Entscheiden",
|
||||
"cases.decideTitle": "Vorgang {id} entscheiden",
|
||||
"cases.decideHint": "Deine Entscheidung gilt für die ganze Instanz. Jeder Agent, der dieselbe Frage stellt, bekommt sie vorher angezeigt. Alle, die den Vorgang eröffnet haben oder daran beteiligt sind, erfahren davon.",
|
||||
"cases.answer": "Entscheidung",
|
||||
"cases.answerPlaceholder": "Was ab jetzt gilt",
|
||||
"cases.reasoning": "Begründung",
|
||||
"cases.reasoningPlaceholder": "Warum. Ohne Begründung lässt sich eine Entscheidung unten nicht anwenden",
|
||||
"cases.decided": "Beschluss gespeichert.",
|
||||
"cases.requestStatement": "Stellungnahme anfordern",
|
||||
"cases.statementTitle": "Stellungnahme zu Vorgang {id} anfordern",
|
||||
"cases.statementFrom": "Von",
|
||||
"cases.question": "Frage",
|
||||
"cases.questionPlaceholder": "Was musst du wissen?",
|
||||
"cases.statementHint": "Der Agent bekommt eine Aufgabe mit genau dieser Frage und einer eigenen Frist. Der Vorgang bleibt bei dir.",
|
||||
"cases.statementRequested": "Stellungnahme angefordert.",
|
||||
"cases.scope": "Gilt für",
|
||||
"cases.scopeAll": "die ganze Instanz",
|
||||
"cases.decidedBy": "Entschieden von",
|
||||
"cases.failed": "Fehlgeschlagen: {error}"
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@
|
||||
"nav.group.system": "System",
|
||||
"nav.instances": "Instances",
|
||||
"nav.staging": "Approvals",
|
||||
"nav.cases": "Cases",
|
||||
"nav.logs": "Logs",
|
||||
"nav.backup": "Backup",
|
||||
"nav.settings": "Settings",
|
||||
@@ -851,5 +852,65 @@
|
||||
"desktop.dialog.addService": "Add Service",
|
||||
"desktop.dialog.restoreBackup": "Restore Backup",
|
||||
"desktop.dialog.license": "License Activation",
|
||||
"desktop.dialog.instancePicker": "Choose Instance"
|
||||
"desktop.dialog.instancePicker": "Choose Instance",
|
||||
|
||||
"cases.title": "Cases",
|
||||
"cases.refresh": "Refresh",
|
||||
"cases.intro": "Escalations and conflicts the agents could not solve among themselves, and the decisions that closed them. A case nobody solves by its deadline comes here.",
|
||||
"cases.tabOpen": "Open",
|
||||
"cases.tabAll": "All",
|
||||
"cases.tabDecisions": "Decisions",
|
||||
"cases.none": "No open cases",
|
||||
"cases.noneHint": "Nothing waits for a decision.",
|
||||
"cases.noDecisions": "No decisions yet",
|
||||
"cases.noDecisionsHint": "Every decided case leaves a decision here. It binds the area it was made for and is shown before anyone asks the same again.",
|
||||
"cases.loadFailed": "Cases could not be loaded",
|
||||
"cases.kindEscalation": "Escalation",
|
||||
"cases.kindConflict": "Conflict",
|
||||
"cases.status.open": "open",
|
||||
"cases.status.decided": "decided",
|
||||
"cases.status.handled": "handled",
|
||||
"cases.overdue": "past deadline",
|
||||
"cases.called": "human called",
|
||||
"cases.id": "ID",
|
||||
"cases.openedBy": "Opened by",
|
||||
"cases.holder": "With",
|
||||
"cases.human": "you (human)",
|
||||
"cases.humanAuthor": "human",
|
||||
"cases.deadline": "Deadline",
|
||||
"cases.level": "Passed on",
|
||||
"cases.parties": "Sides",
|
||||
"cases.informed": "Told, not asked",
|
||||
"cases.waitingFor": "Waiting for",
|
||||
"cases.origin": "Arose from",
|
||||
"cases.trail": "What each level said",
|
||||
"cases.entry.opened": "opened",
|
||||
"cases.entry.joined": "joined",
|
||||
"cases.entry.forwarded": "passed on",
|
||||
"cases.entry.position": "position",
|
||||
"cases.entry.statement": "statement",
|
||||
"cases.entry.statement-requested": "statement requested",
|
||||
"cases.entry.called": "human called",
|
||||
"cases.entry.decided": "decided",
|
||||
"cases.entry.handled": "handled",
|
||||
"cases.entry.note": "note",
|
||||
"cases.decide": "Decide",
|
||||
"cases.decideTitle": "Decide case {id}",
|
||||
"cases.decideHint": "Your decision binds the whole instance and is shown to every agent that raises the same question. Everyone who raised it or is a side of it hears it.",
|
||||
"cases.answer": "Decision",
|
||||
"cases.answerPlaceholder": "What holds from now on",
|
||||
"cases.reasoning": "Reasoning",
|
||||
"cases.reasoningPlaceholder": "Why — without reasons a decision cannot be applied below",
|
||||
"cases.decided": "Decision recorded.",
|
||||
"cases.requestStatement": "Request statement",
|
||||
"cases.statementTitle": "Request a statement for case {id}",
|
||||
"cases.statementFrom": "From",
|
||||
"cases.question": "Question",
|
||||
"cases.questionPlaceholder": "What do you need to know?",
|
||||
"cases.statementHint": "The agent gets a task with exactly this question and its own deadline. The case stays with you.",
|
||||
"cases.statementRequested": "Statement requested.",
|
||||
"cases.scope": "Binds",
|
||||
"cases.scopeAll": "the whole instance",
|
||||
"cases.decidedBy": "Decided by",
|
||||
"cases.failed": "Failed: {error}"
|
||||
}
|
||||
|
||||
@@ -124,6 +124,13 @@
|
||||
<span class="nav-label" data-i18n="nav.staging">Approvals</span>
|
||||
<span id="staging-badge" class="nav-badge" hidden>0</span>
|
||||
</a>
|
||||
<a href="#/cases" class="nav-link" title="Cases" data-i18n-title="nav.cases">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M12 3v6 M8 7l4-4 4 4 M4 13h16v8H4z M9 17h6"/>
|
||||
</svg>
|
||||
<span class="nav-label" data-i18n="nav.cases">Cases</span>
|
||||
<span id="faelle-badge" class="nav-badge" hidden>0</span>
|
||||
</a>
|
||||
<a href="#/protokoll" class="nav-link" title="Logs" data-i18n-title="nav.logs">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20 M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z M8 7h8 M8 11h6"/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace ClawdDotNet.Web.Contracts;
|
||||
|
||||
/// <summary>#689: A case — escalation or conflict — with everything each level said.</summary>
|
||||
public sealed record CaseDto(
|
||||
string Id,
|
||||
string Kind,
|
||||
string Status,
|
||||
string Subject,
|
||||
string OpenedBy,
|
||||
DateTime OpenedAt,
|
||||
DateTime DeadlineUtc,
|
||||
DateTime? HumanCalledAt,
|
||||
// null: a human holds it.
|
||||
string? Holder,
|
||||
string HolderTaskId,
|
||||
int Level,
|
||||
string OriginTaskId,
|
||||
string OriginRunId,
|
||||
IReadOnlyList<string> Parties,
|
||||
IReadOnlyList<string> Informed,
|
||||
IReadOnlyList<string> WaitingFor,
|
||||
IReadOnlyList<CaseEntryDto> Trail,
|
||||
string? DecisionId);
|
||||
|
||||
public sealed record CaseEntryDto(DateTime At, string Author, string Kind, string Text, string? RunId);
|
||||
|
||||
public sealed record DecisionDto(
|
||||
string Id,
|
||||
string Subject,
|
||||
string Question,
|
||||
string Answer,
|
||||
string Reasoning,
|
||||
string DecidedBy,
|
||||
DateTime DecidedAt,
|
||||
string? ScopeUnitId,
|
||||
// "Purchasing / Team A"; null: binds the whole instance.
|
||||
string? ScopePath,
|
||||
string CaseId,
|
||||
string? RunId);
|
||||
|
||||
public sealed record CaseDecisionInputDto(string? Answer, string? Reasoning);
|
||||
|
||||
public sealed record CaseStatementInputDto(string? AgentId, string? Question);
|
||||
@@ -32,6 +32,7 @@ internal static class ApiEndpoints
|
||||
api.MapAnalytics();
|
||||
api.MapLogs();
|
||||
api.MapOrganisation();
|
||||
api.MapCases();
|
||||
api.MapGet("/team", async (AppHost host, CancellationToken ct) => (await host.Team.SnapshotAsync(ct)).ToDto());
|
||||
|
||||
app.MapEvents();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using ClawdDotNet.App;
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace ClawdDotNet.Web.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// #689: Cases and decisions for the human. Reading is the repository's, deciding the desk's —
|
||||
/// the same rules as for an agent, with the human as the one holder above everyone.
|
||||
/// </summary>
|
||||
internal static class CaseEndpoints
|
||||
{
|
||||
public static void MapCases(this RouteGroupBuilder api)
|
||||
{
|
||||
var cases = api.MapGroup("/cases");
|
||||
|
||||
cases.MapGet("/", async (AppHost host, bool? all, CancellationToken ct) =>
|
||||
{
|
||||
if (host.Cases is not { } repo)
|
||||
return Results.Ok(Array.Empty<CaseDto>());
|
||||
var list = all == true ? await repo.ListRecentAsync(200, ct) : await repo.ListOpenAsync(ct);
|
||||
return Results.Ok(list.OrderByDescending(c => c.OpenedAt).Select(ToDto).ToList());
|
||||
});
|
||||
|
||||
cases.MapGet("/{caseId}", async (AppHost host, string caseId, CancellationToken ct) =>
|
||||
host.Cases is { } repo && await repo.GetAsync(caseId, ct) is { } record
|
||||
? Results.Ok(ToDto(record))
|
||||
: Results.NotFound(new OutcomeDto(false, $"No case '{caseId}'.")));
|
||||
|
||||
cases.MapPost("/{caseId}/decide", async (AppHost host, string caseId, CaseDecisionInputDto input, CancellationToken ct) =>
|
||||
{
|
||||
if (host.CaseDesk is not { } desk)
|
||||
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
|
||||
var outcome = await desk.DecideAsync(caseId, CaseRules.Human, input.Answer ?? "", input.Reasoning ?? "", null, ct);
|
||||
return Respond(outcome);
|
||||
});
|
||||
|
||||
cases.MapPost("/{caseId}/statement", async (AppHost host, string caseId, CaseStatementInputDto input, CancellationToken ct) =>
|
||||
{
|
||||
if (host.CaseDesk is not { } desk)
|
||||
return Results.BadRequest(new OutcomeDto(false, "Cases are not available in this instance."));
|
||||
var outcome = await desk.RequestStatementAsync(caseId, input.AgentId ?? "", input.Question ?? "", ct);
|
||||
return Respond(outcome);
|
||||
});
|
||||
|
||||
api.MapGet("/decisions", async (AppHost host, CancellationToken ct) =>
|
||||
{
|
||||
if (host.Cases is not { } repo)
|
||||
return Results.Ok(Array.Empty<DecisionDto>());
|
||||
var org = host.Organisation.Current();
|
||||
return Results.Ok((await repo.ListDecisionsAsync(ct)).Select(d => new DecisionDto(
|
||||
d.Id, d.Subject, d.Question, d.Answer, d.Reasoning, d.DecidedBy, d.DecidedAt,
|
||||
d.ScopeUnitId, d.ScopeUnitId is null ? null : org.PathOf(d.ScopeUnitId), d.CaseId, d.RunId)).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
private static IResult Respond(CaseOutcome outcome)
|
||||
=> outcome.Ok
|
||||
? Results.Ok(new OutcomeDto(true, outcome.Message))
|
||||
: Results.BadRequest(new OutcomeDto(false, outcome.Message));
|
||||
|
||||
private static CaseDto ToDto(CaseRecord c) => new(
|
||||
c.Id, c.Kind.ToString().ToLowerInvariant(), c.Status.ToString().ToLowerInvariant(), c.Subject,
|
||||
c.OpenedBy, c.OpenedAt, c.DeadlineUtc, c.HumanCalledAt, c.HeldByHuman ? null : c.Holder, c.HolderTaskId,
|
||||
c.Level, c.OriginTaskId, c.OriginRunId, c.Parties, c.Informed, c.PendingStatements.Keys.ToList(),
|
||||
c.Trail.Select(e => new CaseEntryDto(e.At, e.Author, e.Kind, e.Text, e.RunId)).ToList(),
|
||||
c.DecisionId);
|
||||
}
|
||||
Reference in New Issue
Block a user