// 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(); } } }) ]); }