Freigabe durch die Leitung innerhalb von Grenzen (#687)

- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der
  Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben,
  Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar)
- LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation
  erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist.
  Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id>
- Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe
  geschlossen. Nach der Frist nur noch der Mensch.
- StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung)
- WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je
  Leitung; Karte in der Organisationsansicht; i18n en/de
- Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-30 20:12:18 +02:00
co-authored by Claude Opus 5.5
parent dafb8241a0
commit f51186269c
27 changed files with 948 additions and 20 deletions
@@ -573,6 +573,16 @@ button.klein {
background: var(--surface-hover);
}
/* #687: a lead that approves everything */
.daten-tabelle tr.zeile-warnung td {
color: var(--warnung);
}
/* #687: a call a lead may still decide — the human's too, but not yet the human's alone */
.staging-bei-leitung {
border-left: 3px solid var(--accent);
}
/* ─── Datenlisten (dl/dt/dd) ─── */
.datenliste {
@@ -95,6 +95,11 @@
"org.ceiling": "Rechte-Obergrenze",
"org.ceilingHint": "Was die Einheit nicht darf, kann ein Mitglied nicht bekommen. Senken entzieht den Mitgliedern Werkzeuge — nach einer Bestätigung.",
"org.ceilingLimit": "Werkzeuge dieser Einheit begrenzen",
"org.approvals": "Freigaben durch die Leitung",
"org.approvalsHint": "Was die Leitung dieser Einheit für die Agenten darunter an deiner Stelle freigeben darf. Eine Operation je Zeile: Werkzeug.operation, oder Werkzeug für alle. Änderungen an Agenten bleiben immer bei dir; eine Leitung gibt nie ihre eigenen Aufrufe frei. Eine Untereinheit darf nicht mehr freigeben als die Einheiten darüber. Leer: du entscheidest alles.",
"org.approvalsOperations": "Operationen",
"org.approvalsPerDay": "Höchstens je Tag",
"org.approvalsHours": "Stunden je Freigabe",
"org.ceilingInherited": "Von oben begrenzt auf: {tools}",
"org.settings": "Sammelwerte",
"org.settingsHint": "Werden nach einer Vorschau in die Agenten geschrieben. Die Einheit merkt sich den Wert nur als Vorgabe für neue Mitglieder — zur Laufzeit übertrumpft nichts einen Agenten.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Aufruf ablehnen",
"staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}",
"staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.",
"staging.leadsTitle": "Freigaben durch Leitungen",
"staging.leadsHint": "Was Leitungen in den letzten 30 Tagen an deiner Stelle freigegeben haben. Eine Leitung, die alles freigibt, verursacht nur Kosten — sieh dir ihre Entscheidungen an.",
"staging.leadName": "Leitung",
"staging.leadRouted": "Vorgelegt",
"staging.leadApproved": "Freigegeben",
"staging.leadRejected": "Abgelehnt",
"staging.leadLapsed": "Dir überlassen",
"staging.leadRate": "Freigabequote",
"staging.leadAllApproved": "Alles freigegeben — prüfe, ob diese Leitung wirklich hinsieht.",
"agentChats.title": "Agenten-Kommunikation (A2A)",
"agentChats.allAgents": "Alle Agenten",
@@ -95,6 +95,11 @@
"org.ceiling": "Tool ceiling",
"org.ceilingHint": "What the unit may not have, a member cannot be granted. Lowering it removes tools from the members — after a confirmation.",
"org.ceilingLimit": "Limit the tools of this unit",
"org.approvals": "Approvals by the lead",
"org.approvalsHint": "What the lead of this unit may approve for the agents below it, instead of you. One operation per line: Tool.operation, or Tool for all of it. Changes to agents always stay with you; a lead never approves its own calls. A sub-unit can grant no more than the units above it. Empty: you decide everything.",
"org.approvalsOperations": "Operations",
"org.approvalsPerDay": "Per day at most",
"org.approvalsHours": "Hours per approval",
"org.ceilingInherited": "Limited from above to: {tools}",
"org.settings": "Unit-wide values",
"org.settingsHint": "Written into the agents after a preview. The unit keeps the value only as a default for new members — nothing overrides an agent at run time.",
@@ -608,6 +613,16 @@
"staging.rejectButton": "Reject Call",
"staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}",
"staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.",
"staging.leadsTitle": "Approvals by leads",
"staging.leadsHint": "What leads approved instead of you in the last 30 days. A lead that approves everything does nothing but cost — look at its decisions.",
"staging.leadName": "Lead",
"staging.leadRouted": "Given",
"staging.leadApproved": "Approved",
"staging.leadRejected": "Rejected",
"staging.leadLapsed": "Left to you",
"staging.leadRate": "Approval rate",
"staging.leadAllApproved": "Everything approved — check whether this lead really looks.",
"agentChats.title": "Agent Communication (A2A)",
"agentChats.allAgents": "All Agents",
@@ -366,6 +366,7 @@ function unitPanel(unit) {
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(approvalsCard(unit));
cards.push(settingsCard(unit));
// Members.
@@ -432,6 +433,35 @@ function ceilingCard(unit) {
]);
}
/**
* #687: What the unit's lead may approve instead of the human. One operation per line —
* "Tool.operation", or "Tool" for all of it. Changes to agents are refused by the server.
*/
function approvalsCard(unit) {
const current = unit.approvals;
const ops = erstelleElement("textarea", { rows: 3, placeholder: "Database.insert\nFileRW.delete" });
ops.value = (current?.operations ?? []).join("\n");
const perDay = erstelleElement("input", { type: "number", min: "0", step: "1", value: String(current?.perDay ?? 20) });
const hours = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(current?.hours ?? 4) });
return erstelleKarte(t("org.approvals"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.approvalsHint") }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.approvalsOperations") }), ops,
erstelleElement("label", { text: t("org.approvalsPerDay") }), perDay,
erstelleElement("label", { text: t("org.approvalsHours") }), hours),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/approvals`, "PUT", {
operations: ops.value.split(/[\n,]/).map((o) => o.trim()).filter(Boolean),
perDay: Number(perDay.value),
hours: Number(hours.value),
reason: reason()
})
})
]);
}
function settingsCard(unit) {
const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
@@ -16,10 +16,15 @@ import {
let wartendeAufrufe = [];
// #687: What a lead may still decide is not yet the human's to do — it is shown, not counted.
function menschWartet(anfrage) {
return !anfrage.withLead;
}
export async function holeWartendeAnzahl() {
try {
const liste = await api("/staging");
return Array.isArray(liste) ? liste.length : 0;
return Array.isArray(liste) ? liste.filter(menschWartet).length : 0;
} catch {
return 0;
}
@@ -48,7 +53,8 @@ export async function rendereStaging() {
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich);
const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich);
container.append(layout);
await ladeFreigaben();
@@ -67,8 +73,9 @@ async function ladeFreigaben() {
container.replaceChildren();
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
aktualisiereStagingBadge(wartendeAufrufe.filter(menschWartet).length);
ladeDauerfreigaben();
ladeLeitungen();
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
@@ -137,6 +144,14 @@ async function ladeFreigaben() {
const teile = [datenliste, argBox];
// #687: A lead decides it within its unit's limits — the human can still decide first.
if (anfrage.withLead) {
teile.unshift(erstelleElement("div", {
klasse: "hinweis-box",
text: t("staging.withLead", { lead: anfrage.withLead, until: formatiereDatumZeit(anfrage.leadUntil) })
}));
}
if (anfrage.suggestAllowSimilar) {
teile.push(
erstelleElement("div", {
@@ -169,7 +184,8 @@ async function ladeFreigaben() {
}
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
aktionen: kopfAktionen
aktionen: kopfAktionen,
klasse: anfrage.withLead ? "staging-bei-leitung" : undefined
});
container.append(karte);
@@ -226,6 +242,44 @@ async function bestaetigeFreigabe(id, gleichartig = false) {
}
}
/**
* #687: How each lead decided what it was given. A lead that approves everything does
* nothing but cost — the rate is the finding, so it is shown, not hidden in the audit.
*/
async function ladeLeitungen() {
const bereich = document.getElementById("staging-leitung-bereich");
if (!bereich) return;
let liste = [];
try { liste = await api("/staging/leads"); } catch { liste = []; }
if (!Array.isArray(liste) || liste.length === 0) {
bereich.replaceChildren();
return;
}
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
tabelle.append(erstelleElement("thead", {}, erstelleElement("tr", {},
...["staging.leadName", "staging.leadRouted", "staging.leadApproved", "staging.leadRejected", "staging.leadLapsed", "staging.leadRate"]
.map((k) => erstelleElement("th", { text: t(k) })))));
const koerper = erstelleElement("tbody");
for (const l of liste) {
const quote = l.approvalRate == null ? "—" : `${Math.round(l.approvalRate * 100)} %`;
// Everything approved, with enough decisions to mean something, is worth a look.
const auffaellig = l.approvalRate === 1 && l.approved >= 5;
koerper.append(erstelleElement("tr", { klasse: auffaellig ? "zeile-warnung" : undefined },
erstelleElement("td", { text: l.leadId }),
erstelleElement("td", { text: String(l.routed) }),
erstelleElement("td", { text: String(l.approved) }),
erstelleElement("td", { text: String(l.rejected) }),
erstelleElement("td", { text: String(l.lapsed) }),
erstelleElement("td", { text: quote, title: auffaellig ? t("staging.leadAllApproved") : "" })));
}
tabelle.append(koerper);
bereich.replaceChildren(erstelleKarte(t("staging.leadsTitle"), [
erstelleElement("p", { klasse: "gedaempft", text: t("staging.leadsHint") }),
tabelle
]));
}
/**
* #641: Die geltenden Dauerfreigaben - "Gleichartiges kuenftig erlauben" - mit Widerruf.
*/