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.
404 lines
14 KiB
TypeScript
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 ?? "—";
|
|
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ö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) => `<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();
|
|
});
|