Files
paliad/frontend/src/client/admin-partner-units.ts
m d50ba363a8 feat(t-paliad-070): partner-units frontend rename + new admin page
Frontend half of the rename:
- New /admin/partner-units page (admin-partner-units.tsx + .ts) with
  full CRUD + member management. Mirrors /admin/team's aesthetic and
  uses the same modal pattern. Card on /admin flips from "Geplant"
  to "Verfügbar" with ICON_BUILDING and a /admin/partner-units link.
- Sidebar gains a "Partner Units" admin nav item between Team and Audit.
- Onboarding form replaces the free-text Dezernat input with a select
  populated from /api/partner-units; submits partner_unit_id which the
  backend uses to insert a membership row in the user-create tx.
- Settings: dezernat tab removed entirely (TabName drops to 3). The
  read-only "Meine Partner Units" view now lives as a card on the
  profile tab. Free-text dezernat input removed from the profile form.
  ~250 lines of admin-CRUD removed; replaced by ~70 lines of read-only
  partner-units summary.
- /admin/team: Dezernat column dropped from the table and the inline
  edit row; "Onboard existing account" modal no longer asks for one.
  Column count drops from 10 to 9.
- /team directory: groups by structured partner_unit_members only;
  drops the free-text fallback grouping and the "Ohne Dezernat" loose
  bucket. Single "Ohne Partner Unit" orphan group catches users in no
  unit.
- i18n: ~30 dezernat.* + onboarding.dezernat + admin.team.col.dezernat
  + admin.card.departments + team.* keys removed; ~30 partner_unit.*
  keys added in DE+EN. "Partner Unit" / "Partner Units" used as a
  loanword in DE.
- /api/departments?include=members → /api/partner-units?include=members
  in team.ts (the only frontend-side fetch URL referencing the old
  endpoints).

go build / vet / test clean. cd frontend && bun run build clean.
2026-04-29 22:14:11 +02:00

404 lines
14 KiB
TypeScript

import { initI18n, onLangChange, t, 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;
}
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 ?? "&mdash;";
const memberCount = u.members.length;
return `<tr data-id="${esc(u.id)}">
<td class="akten-col-title">${esc(u.name)}</td>
<td><span class="akten-office-chip akten-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) => `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<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>
</li>`,
)
.join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)),
);
}
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="akten-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>(".akten-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();
});