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)".
445 lines
16 KiB
TypeScript
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, "&").replace(/"/g, """);
|
|
}
|
|
|
|
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 ?? "—";
|
|
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ö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="">—</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();
|
|
});
|