Ablage je Einheit, Stufe 2: Verwaltung und Organisationsseite (#684)

- OrgAdministration: eine neue Einheit bekommt ihren Bereich (Mitglieder:
  die Einheit samt Untereinheiten, schreiben); reservierte Namen und belegte
  Bereichs-Ids werden als Einheiten-Id abgelehnt. Uebergreifende Bereiche
  anlegen (mit Eigentuemer), Name/Zweck/geschuetzte Ordner aendern,
  Mitgliederliste ersetzen, nur leere Bereiche loeschen, nie den eigenen
  einer Einheit. Eine geloeschte Einheit verlaesst alle Mitgliederlisten;
  ihr Bereich geht nur, wenn er leer ist, sonst bleibt er ohne Eigentuemer.
  Nachruesten des eigenen Bereichs fuer Einheiten von vor #684. Alles im
  AuditLog.
- API unter /api/organisation/workspaces; die Uebersicht liefert je Bereich
  Mitglieder, Dateizahl und - von der Instanz berechnet - wer hineinkommt.
- Organisationsseite: Bereiche unter der Struktur, Bearbeitung mit
  Mitglieder-Editor, "Wer hineinkommt", Bereichskarte an der Einheit,
  Bereiche am Agenten. Sprachschluessel en/de.
- Die Nachschau nach einem Absturz bekommt die Organisation mit, sonst
  fände sie einen Aufruf in einem benannten Bereich nicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 16:58:09 +02:00
co-authored by Claude Opus 5.5
parent ff20ce2563
commit 609298dd7a
9 changed files with 850 additions and 10 deletions
@@ -6,6 +6,10 @@
//
// A list, not a graph: this is where the structure is edited. Watching it work is the team
// overview (#711).
//
// #684: Workspaces sit beside the units. A unit gets its own; a workspace across units is
// made on purpose, with an owner. The right stands at the workspace — its member list — and
// the page shows, computed by the instance, who that list actually lets in.
import { api } from "../modules/api.js";
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
@@ -13,9 +17,10 @@ import { t } from "../modules/i18n.js";
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
const ACCESS = ["Read", "Write", "Manage"];
let data = null;
let selection = null; // { kind: "unit" | "agent", id }
let selection = null; // { kind: "unit" | "agent" | "workspace", id }
let treeBox = null;
let detailBox = null;
let reasonInput = null;
@@ -57,11 +62,35 @@ async function load() {
function find(sel) {
if (!sel || !data) return null;
if (sel.kind === "workspace") return data.workspaces.find((w) => w.id === sel.id);
return sel.kind === "unit"
? data.units.find((u) => u.id === sel.id)
: data.agents.find((a) => a.agentId === sel.id);
}
/** A unit's own workspace, if it has one. */
function ownWorkspace(unitId) {
return data.workspaces.find((w) => w.isUnitOwn && w.ownerUnitId === unitId);
}
function unitPath(unitId) {
return data.units.find((u) => u.id === unitId)?.path ?? unitId;
}
function accessLabel(access) {
return t(`org.access.${access}`);
}
/** "Purchasing (write, incl. sub-units); ads (read)". */
function membersSummary(workspace) {
if (!workspace.members.length) return t("org.noWorkspaceMembers");
return workspace.members.map((m) => {
const who = m.unitId ? unitPath(m.unitId) : m.agentId;
const sub = m.unitId && m.includeSubUnits ? `, ${t("org.includeSubUnits")}` : "";
return `${who} (${accessLabel(m.access)}${sub})`;
}).join("; ");
}
function childrenOf(unitId) {
return data.units.filter((u) => (u.parentId ?? null) === unitId);
}
@@ -180,7 +209,28 @@ function renderTree() {
treeBox.replaceChildren(
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.structure") }), newUnit),
list);
list,
workspaceList());
}
function workspaceList() {
const rows = data.workspaces.map((w) => erstelleElement("button", {
type: "button",
klasse: `org-knoten org-bereich ${selection?.kind === "workspace" && selection.id === w.id ? "aktiv" : ""}`,
onclick: () => select({ kind: "workspace", id: w.id })
},
erstelleElement("span", { klasse: "org-name", text: `▤ ${w.name}` }),
erstelleElement("span", {
klasse: "gedaempft text-klein",
text: `${w.ownerUnitId ? unitPath(w.ownerUnitId) : t("org.noOwner")} · ${t("org.fileCount", { n: w.fileCount })}`
})));
return erstelleElement("div", { klasse: "org-bereiche" },
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
erstelleElement("h2", { text: t("org.workspaces") }),
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.newWorkspace"), onclick: () => openCreateWorkspace() })),
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.workspacesHint") }),
...(rows.length ? rows : [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noWorkspaces") })]));
}
function unitBlock(unit, depth) {
@@ -261,7 +311,10 @@ function renderDetail() {
return;
}
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
const panels = selection.kind === "unit" ? unitPanel(item)
: selection.kind === "workspace" ? workspacePanel(item)
: agentPanel(item);
detailBox.replaceChildren(reasonCard, ...panels);
}
function unitPanel(unit) {
@@ -311,6 +364,7 @@ function unitPanel(unit) {
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
]));
cards.push(unitWorkspaceCard(unit));
cards.push(ceilingCard(unit));
cards.push(settingsCard(unit));
@@ -456,7 +510,8 @@ function agentPanel(agent) {
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(", ") || "—"}` })
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }),
erstelleElement("p", { klasse: "text-klein", text: `${t("org.workspaces")}: ${agentWorkspaces(agent) || "—"}` })
]),
erstelleKarte(t("org.moveAgent"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
@@ -469,3 +524,207 @@ function agentPanel(agent) {
])
];
}
// ─── workspaces (#684) ───
function agentWorkspaces(agent) {
return data.workspaces
.map((w) => ({ w, grant: w.access.find((a) => a.agentId === agent.agentId) }))
.filter((x) => x.grant)
.map((x) => `${x.w.id} (${accessLabel(x.grant.access)})`)
.join(", ");
}
function unitWorkspaceCard(unit) {
const own = ownWorkspace(unit.id);
if (!own) {
return erstelleKarte(t("org.unitWorkspace"), [
erstelleElement("p", { klasse: "text-klein", text: t("org.unitWorkspaceMissing") }),
erstelleElement("button", {
type: "button", klasse: "klein", text: t("org.createUnitWorkspace"),
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/workspace`, "POST", { reason: reason() })
})
]);
}
return erstelleKarte(t("org.unitWorkspace"), [
erstelleElement("p", { klasse: "text-klein", text: `${own.id} · ${t("org.fileCount", { n: own.fileCount })}` }),
erstelleElement("p", { klasse: "text-klein", text: `${t("org.memberEntries")}: ${membersSummary(own)}` }),
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.editWorkspace"),
onclick: () => select({ kind: "workspace", id: own.id })
})
]);
}
function openCreateWorkspace() {
const id = erstelleElement("input", { type: "text", placeholder: "articles", required: true });
const name = erstelleElement("input", { type: "text", placeholder: "Article master", required: true });
const purpose = erstelleElement("textarea", { rows: 2 });
const owner = unitOptions(erstelleElement("select"), null, new Set(), t("org.noOwner"));
const form = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.workspaceId") }), id,
erstelleElement("label", { text: t("org.workspaceName") }), name,
erstelleElement("label", { text: t("org.purpose") }), purpose,
erstelleElement("label", { text: t("org.owner") }), owner,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.newWorkspaceHint") }));
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.newWorkspace"), form, [cancel, create]);
cancel.addEventListener("click", () => modal.schliessen());
form.addEventListener("submit", async (e) => {
e.preventDefault();
// The owning unit manages what it owns; everyone else is added on purpose afterwards.
const members = owner.value ? [{ unitId: owner.value, agentId: null, access: "Manage", includeSubUnits: true }] : [];
const ok = await send("/organisation/workspaces", "POST", {
id: id.value.trim(), name: name.value.trim(), purpose: purpose.value, ownerUnitId: owner.value || null, members, reason: reason()
});
if (ok) {
modal.schliessen();
select({ kind: "workspace", id: id.value.trim() });
}
});
modal.oeffnen();
}
function workspacePanel(workspace) {
const base = `/organisation/workspaces/${encodeURIComponent(workspace.id)}`;
const owner = workspace.ownerUnitId ? unitPath(workspace.ownerUnitId) : t("org.noOwner");
// Name, purpose, protected folders.
const name = erstelleElement("input", { type: "text", value: workspace.name });
const purpose = erstelleElement("textarea", { rows: 2 });
purpose.value = workspace.purpose ?? "";
const protectedPaths = erstelleElement("input", { type: "text", value: workspace.protectedPaths.join(", "), placeholder: "prices, archive" });
const general = erstelleKarte(workspace.name, [
erstelleElement("p", { klasse: "gedaempft text-klein",
text: `${workspace.id} · ${t("org.owner")}: ${owner}${workspace.isUnitOwn ? ` (${t("org.unitOwnWorkspace")})` : ""}` }),
erstelleElement("p", { klasse: "gedaempft text-klein",
text: `${t("org.fileCount", { n: workspace.fileCount })} · SharedWorkspaces/${workspace.id}/` }),
erstelleElement("div", { klasse: "formular-raster" },
erstelleElement("label", { text: t("org.workspaceName") }), name,
erstelleElement("label", { text: t("org.purpose") }), purpose,
erstelleElement("label", { text: t("org.protectedPaths") }), protectedPaths,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.protectedHint") }),
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(base, "PATCH", {
name: name.value,
purpose: purpose.value,
protectedPaths: protectedPaths.value.split(",").map((x) => x.trim()).filter(Boolean),
reason: reason()
})
}))
]);
return [general, membersCard(workspace, base), accessCard(workspace), deleteWorkspaceCard(workspace, base)];
}
/** The member list, edited as a whole and saved in one go. */
function membersCard(workspace, base) {
const rows = erstelleElement("tbody");
const addRow = (member) => {
const kind = erstelleElement("select", {},
erstelleElement("option", { value: "unit", text: t("org.memberUnit") }),
erstelleElement("option", { value: "agent", text: t("org.memberAgent") }));
kind.value = member.agentId ? "agent" : "unit";
const target = erstelleElement("select");
const fillTarget = () => {
target.replaceChildren();
const options = kind.value === "unit"
? data.units.map((u) => [u.id, u.path])
: data.agents.map((a) => [a.agentId, a.displayName && a.displayName !== a.agentId ? `${a.displayName} (${a.agentId})` : a.agentId]);
for (const [value, text] of options) target.append(erstelleElement("option", { value, text }));
const current = kind.value === "unit" ? member.unitId : member.agentId;
if (current) target.value = current;
};
const access = erstelleElement("select", {},
...ACCESS.map((a) => erstelleElement("option", { value: a, text: accessLabel(a) })));
access.value = member.access ?? "Read";
const sub = erstelleElement("input", { type: "checkbox" });
sub.checked = member.includeSubUnits !== false;
const syncSub = () => { sub.disabled = kind.value !== "unit"; };
kind.addEventListener("change", () => { fillTarget(); syncSub(); });
fillTarget();
syncSub();
const row = erstelleElement("tr", {},
erstelleElement("td", {}, kind),
erstelleElement("td", {}, target),
erstelleElement("td", {}, access),
erstelleElement("td", {}, erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, sub, t("org.includeSubUnits"))),
erstelleElement("td", {}, erstelleElement("button", {
type: "button", klasse: "flach klein", text: "×", title: t("org.removeMember"), "aria-label": t("org.removeMember"),
onclick: () => row.remove()
})));
row.lesen = () => ({
unitId: kind.value === "unit" ? target.value : null,
agentId: kind.value === "agent" ? target.value : null,
access: access.value,
includeSubUnits: kind.value === "unit" ? sub.checked : true
});
rows.append(row);
};
for (const member of workspace.members) addRow(member);
return erstelleKarte(t("org.memberEntries"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.membersHint") }),
erstelleElement("table", { klasse: "daten-tabelle org-mitglieder" },
erstelleElement("thead", {}, erstelleElement("tr", {},
...[t("org.memberKind"), t("org.memberWho"), t("org.accessLevel"), "", ""].map((h) => erstelleElement("th", { text: h })))),
rows),
erstelleElement("div", { klasse: "gruppe-horizontal" },
erstelleElement("button", {
type: "button", klasse: "flach klein", text: t("org.addMember"),
onclick: () => addRow({ unitId: data.units[0]?.id ?? null, agentId: data.units.length ? null : data.agents[0]?.agentId, access: "Read" })
}),
erstelleElement("button", {
type: "button", klasse: "primaer klein", text: t("common.save"),
onclick: () => send(`${base}/members`, "PUT", {
members: [...rows.children].map((r) => r.lesen()),
reason: reason()
})
}))
]);
}
/** Who the list lets in — computed by the instance from the tree, not guessed here. */
function accessCard(workspace) {
return erstelleKarte(t("org.whoGetsIn"), [
workspace.access.length
? erstelleElement("ul", { klasse: "org-liste" }, ...workspace.access.map((a) => erstelleElement("li", {},
erstelleElement("button", {
type: "button", klasse: "flach klein", text: `${a.agentId} — ${accessLabel(a.access)}`,
onclick: () => select({ kind: "agent", id: a.agentId })
}))))
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.nobodyHasAccess") })
]);
}
function deleteWorkspaceCard(workspace, base) {
if (workspace.isUnitOwn) {
return erstelleKarte(null, [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.unitWorkspaceStays") })]);
}
return erstelleKarte(null, [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.deleteWorkspaceHint") }),
erstelleElement("button", {
type: "button", klasse: "gefahr klein", text: t("org.deleteWorkspace"),
disabled: workspace.fileCount > 0,
onclick: async () => {
if (await send(`${base}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
selection = null;
renderDetail();
}
}
})
]);
}