Einheiten, Stufe 4: API und WebUI-Seite "Organisation" (#683)

- /api/organisation: Baum (Einheiten mit Pfad, Tiefe, eigener und
  wirksamer Obergrenze, Vorgaben; Agenten mit Einheit, Leitung, effektiver
  Leitung, Werkzeugen, Budget), Probleme der Struktur, bekannte Werkzeuge.
  Anlegen, Aendern, Loeschen, Leitung, Umzug von Agent und Einheit,
  Obergrenze, Sammelwerte mit Vorschau. Umzug und Obergrenze kennen
  preview=true; die Entscheidung trifft allein OrgAdministration.
- Neue Agenten koennen ueber die API gleich in eine Einheit gelegt werden.
- WebUI #/organisation: links der Baum (oberste Ebene, Einheiten, Leitungen
  mit Stern, Mitglieder), rechts die Bearbeitung. Eine Begruendung fuer das
  Audit gilt fuer jede Aenderung. Wo etwas verloren gehen kann, zuerst die
  Vorschau: laufende Agenten und Probleme halten an, wegfallende Werkzeuge
  muessen in einem Dialog bestaetigt werden. Sammelwerte als Tabelle mit
  Ausreissern markiert und abgewaehlt. Bei aktivierter Begrenzung ist
  vorausgewaehlt, was von oben ohnehin erlaubt ist. Sprachpakete en/de.

Geprueft im Browser mit einem Pruefgestell (API durch Attrappe ersetzt):
Baum, Auswahl, Obergrenze mit Bestaetigungsdialog, Sammelwert-Vorschau
mit Ausreisser und das Schreiben nur der gewaehlten Agenten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 12:54:16 +02:00
co-authored by Claude Opus 5.5
parent 68a432fec6
commit e4ecf2d5ca
10 changed files with 866 additions and 2 deletions
@@ -0,0 +1,471 @@
// ClawdDotNet — Organisation (#683)
//
// Units, leads, members — and the three things that change rights: moving, the tool
// ceiling, unit-wide values. Every rule is the instance's (OrgAdministration); this page
// asks for a preview first wherever something could be lost, and shows it before writing.
//
// A list, not a graph: this is where the structure is edited. Watching it work is the team
// overview (#711).
import { api } from "../modules/api.js";
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
import { t } from "../modules/i18n.js";
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
let data = null;
let selection = null; // { kind: "unit" | "agent", id }
let treeBox = null;
let detailBox = null;
let reasonInput = null;
export async function renderOrganisation() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
treeBox = erstelleElement("div", { klasse: "org-baum" });
detailBox = erstelleElement("div", { klasse: "org-detail" });
container.replaceChildren(erstelleElement(
"div",
{ klasse: "org-ansicht" },
erstelleElement("h1", { text: t("org.title") }),
erstelleElement("p", { klasse: "gedaempft", text: t("org.subtitle") }),
erstelleElement("div", { id: "org-probleme" }),
erstelleElement("div", { klasse: "org-raster" }, treeBox, detailBox)
));
await load();
}
async function load() {
try {
data = await api("/organisation");
} catch (err) {
treeBox.replaceChildren(erstelleElement("div", { klasse: "fehler-zentriert", text: err.message }));
return;
}
if (selection && !find(selection)) selection = null;
renderProblems();
renderTree();
renderDetail();
}
// ─── helpers ───
function find(sel) {
if (!sel || !data) return null;
return sel.kind === "unit"
? data.units.find((u) => u.id === sel.id)
: data.agents.find((a) => a.agentId === sel.id);
}
function childrenOf(unitId) {
return data.units.filter((u) => (u.parentId ?? null) === unitId);
}
function membersOf(unitId) {
return data.agents.filter((a) => (a.unitId ?? null) === unitId);
}
function descendantIds(unitId) {
const ids = new Set([unitId]);
let grew = true;
while (grew) {
grew = false;
for (const u of data.units) {
if (u.parentId && ids.has(u.parentId) && !ids.has(u.id)) { ids.add(u.id); grew = true; }
}
}
return ids;
}
function reason() {
return reasonInput?.value.trim() ?? "";
}
async function send(path, method, body, successText) {
try {
const result = await api(path, { method, body });
zeigeMeldung(result?.message ?? successText ?? t("common.saved"), "erfolg");
await load();
return true;
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return false;
}
}
/**
* Moves and ceiling changes: ask for a preview, stop on problems or running agents, show
* the tools that would be lost and let the human confirm — only then write.
*/
async function withPreview(path, method, body) {
let preview;
try {
preview = await api(path, { method, body: { ...body, preview: true } });
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return;
}
if (preview.problems.length) { zeigeMeldung(preview.problems.join(" "), "gefahr", 8000); return; }
if (preview.running.length) {
zeigeMeldung(t("org.runningRefusal", { agents: preview.running.join(", ") }), "warnung", 8000);
return;
}
const lost = Object.entries(preview.toolsLost ?? {});
if (!lost.length) {
await send(path, method, { ...body, preview: false });
return;
}
const list = erstelleElement("ul", { klasse: "org-liste" },
...lost.map(([agent, tools]) => erstelleElement("li", { text: `${agent}: ${tools.join(", ")}` })));
const content = erstelleElement("div", {},
erstelleElement("p", { text: t("org.toolsLostIntro") }), list);
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
const confirm = erstelleElement("button", { type: "button", klasse: "gefahr", text: t("org.toolsLostConfirm") });
const modal = erstelleModal(t("org.toolsLostTitle"), content, [cancel, confirm]);
cancel.addEventListener("click", () => modal.schliessen());
confirm.addEventListener("click", async () => {
modal.schliessen();
await send(path, method, { ...body, preview: false, dropExcessTools: true });
});
modal.oeffnen();
}
function unitOptions(select, current, exclude = new Set(), topLabel = t("org.topLevel")) {
select.append(erstelleElement("option", { value: "", text: topLabel }));
for (const unit of data.units) {
if (exclude.has(unit.id)) continue;
const option = erstelleElement("option", { value: unit.id, text: unit.path });
if (unit.id === current) option.selected = true;
select.append(option);
}
if (!current) select.value = "";
return select;
}
// ─── problems ───
function renderProblems() {
const box = document.getElementById("org-probleme");
if (!box) return;
box.replaceChildren(...(data.problems.length
? [erstelleElement("div", { klasse: "org-probleme" },
erstelleElement("strong", { text: t("org.problems") }),
erstelleElement("ul", {}, ...data.problems.map((p) => erstelleElement("li", { text: p }))))]
: []));
}
// ─── tree ───
function renderTree() {
const newUnit = erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("org.newUnit"), onclick: () => openCreateUnit()
});
const list = erstelleElement("div", { klasse: "org-knoten-liste" });
const root = erstelleElement("div", { klasse: "org-ebene" },
erstelleElement("div", { klasse: "org-ebene-titel gedaempft text-klein", text: t("org.topLevel") }));
for (const agent of membersOf(null)) root.append(agentRow(agent, 0));
list.append(root);
for (const unit of childrenOf(null)) list.append(unitBlock(unit, 0));
treeBox.replaceChildren(
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.structure") }), newUnit),
list);
}
function unitBlock(unit, depth) {
const lead = unit.leaderAgentId ? ` · ${t("org.leadShort")}: ${unit.leaderAgentId}` : "";
const count = membersOf(unit.id).length;
const row = erstelleElement("button", {
type: "button",
klasse: `org-knoten org-einheit ${selection?.kind === "unit" && selection.id === unit.id ? "aktiv" : ""}`,
stil: `padding-left: ${12 + depth * 18}px`,
onclick: () => select({ kind: "unit", id: unit.id })
},
erstelleElement("span", { klasse: "org-name", text: `▣ ${unit.name}` }),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${count}${lead}` }));
const block = erstelleElement("div", {}, row);
for (const agent of membersOf(unit.id)) block.append(agentRow(agent, depth + 1));
for (const child of childrenOf(unit.id)) block.append(unitBlock(child, depth + 1));
return block;
}
function agentRow(agent, depth) {
return erstelleElement("button", {
type: "button",
klasse: `org-knoten org-agent ${selection?.kind === "agent" && selection.id === agent.agentId ? "aktiv" : ""}`,
stil: `padding-left: ${12 + depth * 18}px`,
onclick: () => select({ kind: "agent", id: agent.agentId })
},
erstelleElement("span", { text: `${agent.isLead ? "★ " : "· "}${agent.displayName || agent.agentId}` }),
erstelleElement("span", { klasse: "gedaempft text-klein", text: agent.agentId }));
}
function select(sel) {
selection = sel;
renderTree();
renderDetail();
}
function openCreateUnit(parentId = null) {
const id = erstelleElement("input", { type: "text", placeholder: "marketing", required: true });
const name = erstelleElement("input", { type: "text", placeholder: "Marketing", required: true });
const parent = unitOptions(erstelleElement("select"), parentId);
const goal = erstelleElement("textarea", { rows: 2 });
const form = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.unitId") }), id,
erstelleElement("label", { text: t("org.unitName") }), name,
erstelleElement("label", { text: t("org.parent") }), parent,
erstelleElement("label", { text: t("org.goal") }), goal);
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") });
const modal = erstelleModal(t("org.newUnit"), form, [cancel, create]);
cancel.addEventListener("click", () => modal.schliessen());
form.addEventListener("submit", async (e) => {
e.preventDefault();
const ok = await send("/organisation/units", "POST", {
id: id.value.trim(), name: name.value.trim(), parentId: parent.value || null, goal: goal.value, reason: reason()
});
if (ok) {
modal.schliessen();
select({ kind: "unit", id: id.value.trim() });
}
});
modal.oeffnen();
}
// ─── detail ───
function renderDetail() {
const item = find(selection);
reasonInput = erstelleElement("input", { type: "text", placeholder: t("org.reasonPlaceholder") });
const reasonCard = erstelleKarte(null, [
erstelleElement("label", { klasse: "text-klein gedaempft", text: t("org.reason") }), reasonInput
], { klasse: "org-grund" });
if (!item) {
detailBox.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("org.selectHint") }));
return;
}
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
}
function unitPanel(unit) {
const cards = [];
// Name and goal.
const name = erstelleElement("input", { type: "text", value: unit.name });
const goal = erstelleElement("textarea", { rows: 2 });
goal.value = unit.goal ?? "";
cards.push(erstelleKarte(unit.path, [
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.unitName") }), name,
erstelleElement("label", { text: t("org.goal") }), goal,
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}`, "PATCH",
{ name: name.value, goal: goal.value, reason: reason() })
}))
]));
// Lead.
const lead = erstelleElement("select", {}, erstelleElement("option", { value: "", text: t("org.noLead") }));
for (const member of membersOf(unit.id)) {
const option = erstelleElement("option", { value: member.agentId, text: member.displayName || member.agentId });
if (member.agentId === unit.leaderAgentId) option.selected = true;
lead.append(option);
}
cards.push(erstelleKarte(t("org.lead"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.leadHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, lead,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/leader`, "PUT",
{ agentId: lead.value || null, reason: reason() })
}))
]));
// Position in the tree.
const parent = unitOptions(erstelleElement("select"), unit.parentId, descendantIds(unit.id));
cards.push(erstelleKarte(t("org.position"), [
erstelleElement("div", { klasse: "gruppe-horizontal" }, parent,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.move"),
onclick: () => withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/move`, "POST",
{ target: parent.value || null, reason: reason() })
})),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
]));
cards.push(ceilingCard(unit));
cards.push(settingsCard(unit));
// Members.
const members = membersOf(unit.id);
cards.push(erstelleKarte(t("org.members"), [
members.length
? erstelleElement("ul", { klasse: "org-liste" }, ...members.map((m) => erstelleElement("li", {},
erstelleElement("button", {
type: "button", klasse: "flach klein", text: `${m.isLead ? "★ " : ""}${m.displayName || m.agentId}`,
onclick: () => select({ kind: "agent", id: m.agentId })
}))))
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noMembers") }),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.newSubUnit"), onclick: () => openCreateUnit(unit.id)
}),
erstelleElement("button", {
type: "button", klasse: "gefahr klein", text: t("org.deleteUnit"),
onclick: async () => {
if (await send(`/organisation/units/${encodeURIComponent(unit.id)}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
selection = null;
renderDetail();
}
}
}))
]));
return cards;
}
function ceilingCard(unit) {
const limited = erstelleElement("input", { type: "checkbox" });
limited.checked = Array.isArray(unit.toolCeiling);
const boxes = data.knownTools.map((tool) => {
const box = erstelleElement("input", { type: "checkbox", value: tool });
// Pre-selected: the unit's own ceiling — or, without one, what is allowed from above anyway.
const allowed = unit.toolCeiling ?? unit.effectiveToolCeiling;
box.checked = !allowed || allowed.includes(tool);
return erstelleElement("label", { klasse: "org-werkzeug" }, box, ` ${tool}`);
});
const grid = erstelleElement("div", { klasse: "org-werkzeuge" }, ...boxes);
const sync = () => { grid.hidden = !limited.checked; };
limited.addEventListener("change", sync);
sync();
const inherited = unit.effectiveToolCeiling && !unit.toolCeiling
? erstelleElement("p", { klasse: "text-klein", text: t("org.ceilingInherited", { tools: unit.effectiveToolCeiling.join(", ") }) })
: null;
return erstelleKarte(t("org.ceiling"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.ceilingHint") }),
inherited,
erstelleElement("label", { klasse: "gruppe-horizontal" }, limited, t("org.ceilingLimit")),
grid,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("common.save"),
onclick: () => {
const tools = limited.checked
? [...grid.querySelectorAll("input:checked")].map((b) => b.value)
: null;
withPreview(`/organisation/units/${encodeURIComponent(unit.id)}/ceiling`, "PUT", { tools, reason: reason() });
}
})
]);
}
function settingsCard(unit) {
const setting = erstelleElement("select", {},
...SETTINGS.map((s) => erstelleElement("option", { value: s, text: t(`org.setting.${s}`) })));
const value = erstelleElement("input", { type: "text", placeholder: "10" });
const mode = erstelleElement("select", {},
erstelleElement("option", { value: "PerAgent", text: t("org.modePerAgent") }),
erstelleElement("option", { value: "Distribute", text: t("org.modeDistribute") }));
const remember = erstelleElement("input", { type: "checkbox" });
remember.checked = true;
const result = erstelleElement("div");
setting.addEventListener("change", () => { mode.disabled = !NUMERIC.has(setting.value); });
const preview = async () => {
let rows;
try {
rows = await api(`/organisation/units/${encodeURIComponent(unit.id)}/settings/preview`, {
method: "POST", body: { setting: setting.value, value: value.value.trim(), mode: mode.disabled ? "PerAgent" : mode.value }
});
} catch (err) {
zeigeMeldung(err.message, "gefahr", 8000);
return;
}
if (!rows.length) { result.replaceChildren(erstelleElement("p", { klasse: "fehler", text: t("org.settingInvalid") })); return; }
const checks = new Map();
const table = erstelleElement("table", { klasse: "daten-tabelle" },
erstelleElement("thead", {}, erstelleElement("tr", {},
...["", t("org.agent"), t("org.current"), t("org.proposed"), ""].map((h) => erstelleElement("th", { text: h })))),
erstelleElement("tbody", {}, ...rows.map((row) => {
const box = erstelleElement("input", { type: "checkbox" });
box.checked = row.selectedByDefault;
checks.set(row.agentId, { box, value: row.proposed });
return erstelleElement("tr", { klasse: row.outlier ? "org-ausreisser" : "" },
erstelleElement("td", {}, box),
erstelleElement("td", { text: row.displayName || row.agentId }),
erstelleElement("td", { text: row.current }),
erstelleElement("td", { text: row.proposed }),
erstelleElement("td", {}, row.outlier ? erstellePlakette(`⚠ ${t("org.outlier")}`, "warnung") : ""));
})));
const apply = erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("org.apply"),
onclick: () => {
const values = {};
for (const [agentId, { box, value: v }] of checks) if (box.checked) values[agentId] = v;
send(`/organisation/units/${encodeURIComponent(unit.id)}/settings`, "POST", {
setting: setting.value, values, reason: reason(),
defaultValue: remember.checked && mode.value === "PerAgent" ? value.value.trim() : null
});
}
});
result.replaceChildren(
rows.some((r) => r.outlier) ? erstelleElement("p", { klasse: "text-klein", text: `⚠ ${t("org.outlierHint")}` }) : "",
table,
erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, remember, t("org.rememberDefault")),
apply);
};
return erstelleKarte(t("org.settings"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.settingsHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal org-einstellung" }, setting, value, mode,
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.preview"), onclick: preview })),
result
]);
}
function agentPanel(agent) {
const unit = data.units.find((u) => u.id === agent.unitId);
const target = unitOptions(erstelleElement("select"), agent.unitId);
return [
erstelleKarte(agent.displayName || agent.agentId, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${agent.agentId} · ${agent.model}` }),
erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }),
erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }),
agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null,
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` })
]),
erstelleKarte(t("org.moveAgent"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
erstelleElement("div", { klasse: "gruppe-horizontal" }, target,
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.move"),
onclick: () => withPreview(`/organisation/agents/${encodeURIComponent(agent.agentId)}/move`, "POST",
{ target: target.value || null, reason: reason() })
}))
])
];
}