Files
ClawdDotNet/src/ClawdDotNet.Manager.Web/wwwroot/views/organisation.js
T
RichardandClaude Opus 5.5 609298dd7a 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>
2026-09-24 16:58:09 +02:00

731 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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).
//
// #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";
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" | "workspace", 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;
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);
}
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,
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) {
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;
}
const panels = selection.kind === "unit" ? unitPanel(item)
: selection.kind === "workspace" ? workspacePanel(item)
: agentPanel(item);
detailBox.replaceChildren(reasonCard, ...panels);
}
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(unitWorkspaceCard(unit));
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(", ") || "—"}` }),
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") }),
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() })
}))
])
];
}
// ─── 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();
}
}
})
]);
}