Files
paliad/frontend/src/client/admin-partner-units.ts
m bfc48b1420 fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":

1. **All derived members labeled 'Attorney'.** Migration 055 added
   partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
   the column in the admin UI. So 100% of pum rows are 'attorney' and
   Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
   surfaces every member as 'attorney' even when they're really PAs.

2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
   ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
   wins, every other unit-membership dropped. Judith Molarinho Vaz +
   Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.

**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
  UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
  all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
  bool_or(derive_grants_authority), GROUP BY user. One row per user, every
  (unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
  doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
  directly into the Go struct. Pinned by unit test.

**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
  (unit, role) source — single-unit users render as before
  ("über: **Lehment** [Sicht]"); multi-unit users render
  "über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.

**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
  change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
  already shipped in t-paliad-139 Phase 2). Disables during request,
  rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
  admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.

**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).

**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
  returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
  and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
  unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).

After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
2026-05-06 17:16:17 +02:00

445 lines
16 KiB
TypeScript

import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface PartnerUnit {
id: string;
name: string;
lead_user_id?: string | null;
office: string;
created_at: string;
updated_at: string;
}
interface Member {
user_id: string;
email: string;
display_name: string;
office: string;
job_title: string | null;
unit_role: string;
}
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string;
lead_email?: string;
members: Member[];
}
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface UserOption {
id: string;
display_name: string;
email: string;
}
let units: PartnerUnitWithMembers[] = [];
let offices: Office[] = [];
let userOptions: UserOption[] = [];
let activeUnitID: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return getLang() === "de" ? o.label_de : o.label_en;
}
async function loadAll(): Promise<void> {
await Promise.all([loadOffices(), loadUnits(), loadUsers()]);
render();
}
async function loadOffices(): Promise<void> {
const resp = await fetch("/api/offices");
if (resp.ok) offices = (await resp.json()) as Office[];
}
async function loadUnits(): Promise<void> {
const resp = await fetch("/api/partner-units?include=members");
if (resp.ok) units = (await resp.json()) as PartnerUnitWithMembers[];
}
async function loadUsers(): Promise<void> {
const resp = await fetch("/api/users");
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
}
function showFeedback(msg: string, isError: boolean): void {
const el = document.getElementById("pu-feedback")!;
el.textContent = msg;
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
el.style.display = "block";
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
}
function render(): void {
const tbody = document.getElementById("pu-tbody")!;
const empty = document.getElementById("pu-empty")!;
if (!units.length) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
tbody.innerHTML = units
.map((u) => {
const lead = u.lead_display_name ?? "&mdash;";
const memberCount = u.members.length;
return `<tr data-id="${esc(u.id)}">
<td class="entity-col-title">${esc(u.name)}</td>
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${esc(lead)}</td>
<td>${memberCount}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link pu-members-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.members">Mitglieder</button>
<button type="button" class="btn-link pu-edit-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.edit">Bearbeiten</button>
<button type="button" class="btn-link pu-delete-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.delete">L&ouml;schen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".pu-members-btn").forEach((b) =>
b.addEventListener("click", () => openMembersModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-edit-btn").forEach((b) =>
b.addEventListener("click", () => openEditModal(b.dataset.id!)),
);
tbody.querySelectorAll<HTMLButtonElement>(".pu-delete-btn").forEach((b) =>
b.addEventListener("click", () => deleteUnit(b.dataset.id!)),
);
}
// ---- Edit modal -----------------------------------------------------------
function openEditModal(id: string | null): void {
const modal = document.getElementById("pu-edit-modal")!;
const titleEl = document.getElementById("pu-edit-title")!;
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
msg.textContent = "";
msg.className = "form-msg";
// Populate office options.
officeSel.innerHTML = offices
.map((o) => `<option value="${esc(o.key)}">${esc(officeLabel(o.key))}</option>`)
.join("");
// Populate lead options (sorted).
const leadEntries = userOptions
.slice()
.sort((a, b) => (a.display_name || a.email).localeCompare(b.display_name || b.email));
leadSel.innerHTML =
`<option value="">&mdash;</option>` +
leadEntries
.map((u) => {
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
return `<option value="${esc(u.id)}">${esc(label)}</option>`;
})
.join("");
if (id) {
const u = units.find((x) => x.id === id);
if (!u) return;
titleEl.setAttribute("data-i18n", "admin.partner_units.edit.heading");
titleEl.textContent = t("admin.partner_units.edit.heading") || "Partner Unit bearbeiten";
idField.value = u.id;
nameField.value = u.name;
officeSel.value = u.office;
leadSel.value = u.lead_user_id ?? "";
} else {
titleEl.setAttribute("data-i18n", "admin.partner_units.new.heading");
titleEl.textContent = t("admin.partner_units.new.heading") || "Partner Unit anlegen";
idField.value = "";
nameField.value = "";
officeSel.value = offices[0]?.key ?? "munich";
leadSel.value = "";
}
modal.style.display = "flex";
nameField.focus();
}
function closeEditModal(): void {
document.getElementById("pu-edit-modal")!.style.display = "none";
}
async function submitEdit(e: Event): Promise<void> {
e.preventDefault();
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
const msg = document.getElementById("pu-edit-msg")!;
const name = nameField.value.trim();
if (!name) {
msg.textContent = t("admin.partner_units.error.name_required") || "Name erforderlich";
msg.className = "form-msg form-msg-error";
return;
}
const isEdit = !!idField.value;
// Server treats missing keys as "no change". For lead clearing we send the
// nil UUID — service code interprets that as "explicit clear".
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
const payload: Record<string, unknown> = {
name,
office: officeSel.value,
lead_user_id: leadSel.value || NIL_UUID,
};
const url = isEdit ? `/api/partner-units/${idField.value}` : "/api/partner-units";
const method = isEdit ? "PATCH" : "POST";
const resp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
closeEditModal();
await loadUnits();
render();
showFeedback(
isEdit
? t("admin.partner_units.feedback.updated") || "Aktualisiert."
: t("admin.partner_units.feedback.created") || "Angelegt.",
false,
);
}
async function deleteUnit(id: string): Promise<void> {
const u = units.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.partner_units.confirm_delete") || "Partner Unit \"{name}\" wirklich löschen?")
.replace("{name}", u.name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/partner-units/${id}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
return;
}
await loadUnits();
render();
showFeedback(t("admin.partner_units.feedback.deleted") || "Gelöscht.", false);
}
// ---- Members modal --------------------------------------------------------
function openMembersModal(id: string): void {
activeUnitID = id;
const u = units.find((x) => x.id === id);
if (!u) return;
const titleEl = document.getElementById("pu-members-title")!;
titleEl.textContent =
(t("admin.partner_units.member.heading") || "Mitglieder verwalten") + " — " + u.name;
renderMemberList();
// Reset add form
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
(document.getElementById("pu-add-user-id") as HTMLInputElement).value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
const msg = document.getElementById("pu-add-msg")!;
msg.textContent = "";
msg.className = "form-msg";
document.getElementById("pu-members-modal")!.style.display = "flex";
}
function closeMembersModal(): void {
document.getElementById("pu-members-modal")!.style.display = "none";
activeUnitID = null;
}
function renderMemberList(): void {
if (!activeUnitID) return;
const u = units.find((x) => x.id === activeUnitID);
if (!u) return;
const list = document.getElementById("pu-members-list")!;
if (!u.members.length) {
list.innerHTML = `<li class="form-hint">${esc(t("admin.partner_units.member.empty") || "Noch keine Mitglieder.")}</li>`;
return;
}
list.innerHTML = u.members
.map((m) => {
const roleOptions = UNIT_ROLES.map((r) => {
const label = tDyn(`unit_role.${r}`) || r;
const sel = m.unit_role === r ? " selected" : "";
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
}).join("");
return `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<span class="partner-unit-member-actions">
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</span>
</li>`;
})
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
);
}
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
if (!activeUnitID) return;
// Snapshot the prior selection so we can roll back on failure.
const u = units.find((x) => x.id === activeUnitID);
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
sel.disabled = true;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unit_role: role }),
},
);
sel.disabled = false;
if (!resp.ok) {
if (prior !== undefined) sel.value = prior;
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
return;
}
await loadUnits();
renderMemberList();
render();
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
}
function wireSuggestions(): void {
const input = document.getElementById("pu-add-input") as HTMLInputElement;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const sugs = document.getElementById("pu-add-suggestions")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
.slice(0, 8);
sugs.innerHTML = matches
.map(
(u) => `<div class="collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
<strong>${esc(u.display_name || u.email)}</strong>
<span class="form-hint">${esc(u.email)}</span>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
});
});
});
}
async function submitAddMember(e: Event): Promise<void> {
e.preventDefault();
if (!activeUnitID) return;
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
const msg = document.getElementById("pu-add-msg")!;
if (!hidden.value) {
msg.textContent = t("admin.partner_units.error.user_required") || "Benutzer auswählen";
msg.className = "form-msg form-msg-error";
return;
}
const resp = await fetch(`/api/partner-units/${activeUnitID}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value }),
});
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
msg.textContent = body.error || "Fehler.";
msg.className = "form-msg form-msg-error";
return;
}
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
hidden.value = "";
document.getElementById("pu-add-suggestions")!.innerHTML = "";
msg.textContent = "";
await loadUnits();
renderMemberList();
render();
}
async function removeMember(userID: string): Promise<void> {
if (!activeUnitID) return;
const confirmMsg = t("admin.partner_units.member.confirm_remove") || "Mitglied entfernen?";
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}`,
{ method: "DELETE" },
);
if (!resp.ok && resp.status !== 204) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Entfernen fehlgeschlagen.", true);
return;
}
await loadUnits();
renderMemberList();
render();
}
// ---- Init -----------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
document.getElementById("pu-new-btn")!.addEventListener("click", () => openEditModal(null));
document.getElementById("pu-edit-close")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-cancel")!.addEventListener("click", closeEditModal);
document.getElementById("pu-edit-form")!.addEventListener("submit", submitEdit);
document.getElementById("pu-edit-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
document.getElementById("pu-members-close")!.addEventListener("click", closeMembersModal);
document.getElementById("pu-add-form")!.addEventListener("submit", submitAddMember);
document.getElementById("pu-members-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeMembersModal();
});
wireSuggestions();
onLangChange(() => render());
void loadAll();
});