- 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>
731 lines
31 KiB
JavaScript
731 lines
31 KiB
JavaScript
// 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();
|
||
}
|
||
}
|
||
})
|
||
]);
|
||
}
|