Ablage je Einheit, Stufe 2: Verwaltung und Organisationsseite (#684)
- OrgAdministration: eine neue Einheit bekommt ihren Bereich (Mitglieder: die Einheit samt Untereinheiten, schreiben); reservierte Namen und belegte Bereichs-Ids werden als Einheiten-Id abgelehnt. Uebergreifende Bereiche anlegen (mit Eigentuemer), Name/Zweck/geschuetzte Ordner aendern, Mitgliederliste ersetzen, nur leere Bereiche loeschen, nie den eigenen einer Einheit. Eine geloeschte Einheit verlaesst alle Mitgliederlisten; ihr Bereich geht nur, wenn er leer ist, sonst bleibt er ohne Eigentuemer. Nachruesten des eigenen Bereichs fuer Einheiten von vor #684. Alles im AuditLog. - API unter /api/organisation/workspaces; die Uebersicht liefert je Bereich Mitglieder, Dateizahl und - von der Instanz berechnet - wer hineinkommt. - Organisationsseite: Bereiche unter der Struktur, Bearbeitung mit Mitglieder-Editor, "Wer hineinkommt", Bereichskarte an der Einheit, Bereiche am Agenten. Sprachschluessel en/de. - Die Nachschau nach einem Absturz bekommt die Organisation mit, sonst fände sie einen Aufruf in einem benannten Bereich nicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff20ce2563
commit
609298dd7a
@@ -6,6 +6,10 @@
|
||||
//
|
||||
// A list, not a graph: this is where the structure is edited. Watching it work is the team
|
||||
// overview (#711).
|
||||
//
|
||||
// #684: Workspaces sit beside the units. A unit gets its own; a workspace across units is
|
||||
// made on purpose, with an owner. The right stands at the workspace — its member list — and
|
||||
// the page shows, computed by the instance, who that list actually lets in.
|
||||
|
||||
import { api } from "../modules/api.js";
|
||||
import { erstelleElement, erstelleKarte, erstellePlakette, erstelleModal, zeigeMeldung, formatiereDatumZeit } from "../modules/dom.js";
|
||||
@@ -13,9 +17,10 @@ import { t } from "../modules/i18n.js";
|
||||
|
||||
const SETTINGS = ["DailyCostUsd", "DailyTokens", "Model", "WhenExhausted"];
|
||||
const NUMERIC = new Set(["DailyCostUsd", "DailyTokens"]);
|
||||
const ACCESS = ["Read", "Write", "Manage"];
|
||||
|
||||
let data = null;
|
||||
let selection = null; // { kind: "unit" | "agent", id }
|
||||
let selection = null; // { kind: "unit" | "agent" | "workspace", id }
|
||||
let treeBox = null;
|
||||
let detailBox = null;
|
||||
let reasonInput = null;
|
||||
@@ -57,11 +62,35 @@ async function load() {
|
||||
|
||||
function find(sel) {
|
||||
if (!sel || !data) return null;
|
||||
if (sel.kind === "workspace") return data.workspaces.find((w) => w.id === sel.id);
|
||||
return sel.kind === "unit"
|
||||
? data.units.find((u) => u.id === sel.id)
|
||||
: data.agents.find((a) => a.agentId === sel.id);
|
||||
}
|
||||
|
||||
/** A unit's own workspace, if it has one. */
|
||||
function ownWorkspace(unitId) {
|
||||
return data.workspaces.find((w) => w.isUnitOwn && w.ownerUnitId === unitId);
|
||||
}
|
||||
|
||||
function unitPath(unitId) {
|
||||
return data.units.find((u) => u.id === unitId)?.path ?? unitId;
|
||||
}
|
||||
|
||||
function accessLabel(access) {
|
||||
return t(`org.access.${access}`);
|
||||
}
|
||||
|
||||
/** "Purchasing (write, incl. sub-units); ads (read)". */
|
||||
function membersSummary(workspace) {
|
||||
if (!workspace.members.length) return t("org.noWorkspaceMembers");
|
||||
return workspace.members.map((m) => {
|
||||
const who = m.unitId ? unitPath(m.unitId) : m.agentId;
|
||||
const sub = m.unitId && m.includeSubUnits ? `, ${t("org.includeSubUnits")}` : "";
|
||||
return `${who} (${accessLabel(m.access)}${sub})`;
|
||||
}).join("; ");
|
||||
}
|
||||
|
||||
function childrenOf(unitId) {
|
||||
return data.units.filter((u) => (u.parentId ?? null) === unitId);
|
||||
}
|
||||
@@ -180,7 +209,28 @@ function renderTree() {
|
||||
treeBox.replaceChildren(
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
|
||||
erstelleElement("h2", { text: t("org.structure") }), newUnit),
|
||||
list);
|
||||
list,
|
||||
workspaceList());
|
||||
}
|
||||
|
||||
function workspaceList() {
|
||||
const rows = data.workspaces.map((w) => erstelleElement("button", {
|
||||
type: "button",
|
||||
klasse: `org-knoten org-bereich ${selection?.kind === "workspace" && selection.id === w.id ? "aktiv" : ""}`,
|
||||
onclick: () => select({ kind: "workspace", id: w.id })
|
||||
},
|
||||
erstelleElement("span", { klasse: "org-name", text: `▤ ${w.name}` }),
|
||||
erstelleElement("span", {
|
||||
klasse: "gedaempft text-klein",
|
||||
text: `${w.ownerUnitId ? unitPath(w.ownerUnitId) : t("org.noOwner")} · ${t("org.fileCount", { n: w.fileCount })}`
|
||||
})));
|
||||
|
||||
return erstelleElement("div", { klasse: "org-bereiche" },
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal org-baum-kopf" },
|
||||
erstelleElement("h2", { text: t("org.workspaces") }),
|
||||
erstelleElement("button", { type: "button", klasse: "klein", text: t("org.newWorkspace"), onclick: () => openCreateWorkspace() })),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.workspacesHint") }),
|
||||
...(rows.length ? rows : [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.noWorkspaces") })]));
|
||||
}
|
||||
|
||||
function unitBlock(unit, depth) {
|
||||
@@ -261,7 +311,10 @@ function renderDetail() {
|
||||
return;
|
||||
}
|
||||
|
||||
detailBox.replaceChildren(reasonCard, ...(selection.kind === "unit" ? unitPanel(item) : agentPanel(item)));
|
||||
const panels = selection.kind === "unit" ? unitPanel(item)
|
||||
: selection.kind === "workspace" ? workspacePanel(item)
|
||||
: agentPanel(item);
|
||||
detailBox.replaceChildren(reasonCard, ...panels);
|
||||
}
|
||||
|
||||
function unitPanel(unit) {
|
||||
@@ -311,6 +364,7 @@ function unitPanel(unit) {
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveUnitHint") })
|
||||
]));
|
||||
|
||||
cards.push(unitWorkspaceCard(unit));
|
||||
cards.push(ceilingCard(unit));
|
||||
cards.push(settingsCard(unit));
|
||||
|
||||
@@ -456,7 +510,8 @@ function agentPanel(agent) {
|
||||
erstelleElement("p", { text: `${t("org.unit")}: ${unit ? unit.path : t("org.topLevel")}${agent.isLead ? ` (★ ${t("org.lead")})` : ""}` }),
|
||||
erstelleElement("p", { text: `${t("org.answersTo")}: ${agent.effectiveLeadId ?? t("org.human")}` }),
|
||||
agent.unitSinceUtc ? erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.since", { time: formatiereDatumZeit(agent.unitSinceUtc) }) }) : null,
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` })
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.tools")}: ${agent.tools.join(", ") || "—"}` }),
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.workspaces")}: ${agentWorkspaces(agent) || "—"}` })
|
||||
]),
|
||||
erstelleKarte(t("org.moveAgent"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.moveAgentHint") }),
|
||||
@@ -469,3 +524,207 @@ function agentPanel(agent) {
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
// ─── workspaces (#684) ───
|
||||
|
||||
function agentWorkspaces(agent) {
|
||||
return data.workspaces
|
||||
.map((w) => ({ w, grant: w.access.find((a) => a.agentId === agent.agentId) }))
|
||||
.filter((x) => x.grant)
|
||||
.map((x) => `${x.w.id} (${accessLabel(x.grant.access)})`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function unitWorkspaceCard(unit) {
|
||||
const own = ownWorkspace(unit.id);
|
||||
if (!own) {
|
||||
return erstelleKarte(t("org.unitWorkspace"), [
|
||||
erstelleElement("p", { klasse: "text-klein", text: t("org.unitWorkspaceMissing") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "klein", text: t("org.createUnitWorkspace"),
|
||||
onclick: () => send(`/organisation/units/${encodeURIComponent(unit.id)}/workspace`, "POST", { reason: reason() })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
return erstelleKarte(t("org.unitWorkspace"), [
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${own.id} · ${t("org.fileCount", { n: own.fileCount })}` }),
|
||||
erstelleElement("p", { klasse: "text-klein", text: `${t("org.memberEntries")}: ${membersSummary(own)}` }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: t("org.editWorkspace"),
|
||||
onclick: () => select({ kind: "workspace", id: own.id })
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
function openCreateWorkspace() {
|
||||
const id = erstelleElement("input", { type: "text", placeholder: "articles", required: true });
|
||||
const name = erstelleElement("input", { type: "text", placeholder: "Article master", required: true });
|
||||
const purpose = erstelleElement("textarea", { rows: 2 });
|
||||
const owner = unitOptions(erstelleElement("select"), null, new Set(), t("org.noOwner"));
|
||||
|
||||
const form = erstelleElement("form", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.workspaceId") }), id,
|
||||
erstelleElement("label", { text: t("org.workspaceName") }), name,
|
||||
erstelleElement("label", { text: t("org.purpose") }), purpose,
|
||||
erstelleElement("label", { text: t("org.owner") }), owner,
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.newWorkspaceHint") }));
|
||||
|
||||
const cancel = erstelleElement("button", { type: "button", klasse: "flach", text: t("common.cancel") });
|
||||
const create = erstelleElement("button", { type: "submit", klasse: "primaer", text: t("common.create") });
|
||||
const modal = erstelleModal(t("org.newWorkspace"), form, [cancel, create]);
|
||||
cancel.addEventListener("click", () => modal.schliessen());
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
// The owning unit manages what it owns; everyone else is added on purpose afterwards.
|
||||
const members = owner.value ? [{ unitId: owner.value, agentId: null, access: "Manage", includeSubUnits: true }] : [];
|
||||
const ok = await send("/organisation/workspaces", "POST", {
|
||||
id: id.value.trim(), name: name.value.trim(), purpose: purpose.value, ownerUnitId: owner.value || null, members, reason: reason()
|
||||
});
|
||||
if (ok) {
|
||||
modal.schliessen();
|
||||
select({ kind: "workspace", id: id.value.trim() });
|
||||
}
|
||||
});
|
||||
modal.oeffnen();
|
||||
}
|
||||
|
||||
function workspacePanel(workspace) {
|
||||
const base = `/organisation/workspaces/${encodeURIComponent(workspace.id)}`;
|
||||
const owner = workspace.ownerUnitId ? unitPath(workspace.ownerUnitId) : t("org.noOwner");
|
||||
|
||||
// Name, purpose, protected folders.
|
||||
const name = erstelleElement("input", { type: "text", value: workspace.name });
|
||||
const purpose = erstelleElement("textarea", { rows: 2 });
|
||||
purpose.value = workspace.purpose ?? "";
|
||||
const protectedPaths = erstelleElement("input", { type: "text", value: workspace.protectedPaths.join(", "), placeholder: "prices, archive" });
|
||||
|
||||
const general = erstelleKarte(workspace.name, [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein",
|
||||
text: `${workspace.id} · ${t("org.owner")}: ${owner}${workspace.isUnitOwn ? ` (${t("org.unitOwnWorkspace")})` : ""}` }),
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein",
|
||||
text: `${t("org.fileCount", { n: workspace.fileCount })} · SharedWorkspaces/${workspace.id}/` }),
|
||||
erstelleElement("div", { klasse: "formular-raster" },
|
||||
erstelleElement("label", { text: t("org.workspaceName") }), name,
|
||||
erstelleElement("label", { text: t("org.purpose") }), purpose,
|
||||
erstelleElement("label", { text: t("org.protectedPaths") }), protectedPaths,
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.protectedHint") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "primaer klein", text: t("common.save"),
|
||||
onclick: () => send(base, "PATCH", {
|
||||
name: name.value,
|
||||
purpose: purpose.value,
|
||||
protectedPaths: protectedPaths.value.split(",").map((x) => x.trim()).filter(Boolean),
|
||||
reason: reason()
|
||||
})
|
||||
}))
|
||||
]);
|
||||
|
||||
return [general, membersCard(workspace, base), accessCard(workspace), deleteWorkspaceCard(workspace, base)];
|
||||
}
|
||||
|
||||
/** The member list, edited as a whole and saved in one go. */
|
||||
function membersCard(workspace, base) {
|
||||
const rows = erstelleElement("tbody");
|
||||
|
||||
const addRow = (member) => {
|
||||
const kind = erstelleElement("select", {},
|
||||
erstelleElement("option", { value: "unit", text: t("org.memberUnit") }),
|
||||
erstelleElement("option", { value: "agent", text: t("org.memberAgent") }));
|
||||
kind.value = member.agentId ? "agent" : "unit";
|
||||
|
||||
const target = erstelleElement("select");
|
||||
const fillTarget = () => {
|
||||
target.replaceChildren();
|
||||
const options = kind.value === "unit"
|
||||
? data.units.map((u) => [u.id, u.path])
|
||||
: data.agents.map((a) => [a.agentId, a.displayName && a.displayName !== a.agentId ? `${a.displayName} (${a.agentId})` : a.agentId]);
|
||||
for (const [value, text] of options) target.append(erstelleElement("option", { value, text }));
|
||||
const current = kind.value === "unit" ? member.unitId : member.agentId;
|
||||
if (current) target.value = current;
|
||||
};
|
||||
|
||||
const access = erstelleElement("select", {},
|
||||
...ACCESS.map((a) => erstelleElement("option", { value: a, text: accessLabel(a) })));
|
||||
access.value = member.access ?? "Read";
|
||||
|
||||
const sub = erstelleElement("input", { type: "checkbox" });
|
||||
sub.checked = member.includeSubUnits !== false;
|
||||
const syncSub = () => { sub.disabled = kind.value !== "unit"; };
|
||||
|
||||
kind.addEventListener("change", () => { fillTarget(); syncSub(); });
|
||||
fillTarget();
|
||||
syncSub();
|
||||
|
||||
const row = erstelleElement("tr", {},
|
||||
erstelleElement("td", {}, kind),
|
||||
erstelleElement("td", {}, target),
|
||||
erstelleElement("td", {}, access),
|
||||
erstelleElement("td", {}, erstelleElement("label", { klasse: "gruppe-horizontal text-klein" }, sub, t("org.includeSubUnits"))),
|
||||
erstelleElement("td", {}, erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: "×", title: t("org.removeMember"), "aria-label": t("org.removeMember"),
|
||||
onclick: () => row.remove()
|
||||
})));
|
||||
row.lesen = () => ({
|
||||
unitId: kind.value === "unit" ? target.value : null,
|
||||
agentId: kind.value === "agent" ? target.value : null,
|
||||
access: access.value,
|
||||
includeSubUnits: kind.value === "unit" ? sub.checked : true
|
||||
});
|
||||
rows.append(row);
|
||||
};
|
||||
|
||||
for (const member of workspace.members) addRow(member);
|
||||
|
||||
return erstelleKarte(t("org.memberEntries"), [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.membersHint") }),
|
||||
erstelleElement("table", { klasse: "daten-tabelle org-mitglieder" },
|
||||
erstelleElement("thead", {}, erstelleElement("tr", {},
|
||||
...[t("org.memberKind"), t("org.memberWho"), t("org.accessLevel"), "", ""].map((h) => erstelleElement("th", { text: h })))),
|
||||
rows),
|
||||
erstelleElement("div", { klasse: "gruppe-horizontal" },
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: t("org.addMember"),
|
||||
onclick: () => addRow({ unitId: data.units[0]?.id ?? null, agentId: data.units.length ? null : data.agents[0]?.agentId, access: "Read" })
|
||||
}),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "primaer klein", text: t("common.save"),
|
||||
onclick: () => send(`${base}/members`, "PUT", {
|
||||
members: [...rows.children].map((r) => r.lesen()),
|
||||
reason: reason()
|
||||
})
|
||||
}))
|
||||
]);
|
||||
}
|
||||
|
||||
/** Who the list lets in — computed by the instance from the tree, not guessed here. */
|
||||
function accessCard(workspace) {
|
||||
return erstelleKarte(t("org.whoGetsIn"), [
|
||||
workspace.access.length
|
||||
? erstelleElement("ul", { klasse: "org-liste" }, ...workspace.access.map((a) => erstelleElement("li", {},
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "flach klein", text: `${a.agentId} — ${accessLabel(a.access)}`,
|
||||
onclick: () => select({ kind: "agent", id: a.agentId })
|
||||
}))))
|
||||
: erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.nobodyHasAccess") })
|
||||
]);
|
||||
}
|
||||
|
||||
function deleteWorkspaceCard(workspace, base) {
|
||||
if (workspace.isUnitOwn) {
|
||||
return erstelleKarte(null, [erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.unitWorkspaceStays") })]);
|
||||
}
|
||||
return erstelleKarte(null, [
|
||||
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("org.deleteWorkspaceHint") }),
|
||||
erstelleElement("button", {
|
||||
type: "button", klasse: "gefahr klein", text: t("org.deleteWorkspace"),
|
||||
disabled: workspace.fileCount > 0,
|
||||
onclick: async () => {
|
||||
if (await send(`${base}?reason=${encodeURIComponent(reason())}`, "DELETE")) {
|
||||
selection = null;
|
||||
renderDetail();
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user