Files
paliad/frontend/src/client/admin-team.ts
m 2af4bf1f88 feat(t-paliad-148) commit 5/6: frontend — team-add dropdown + 3-col team table + admin-team profession + onboarding
projects-detail.tsx (the bug surface):
- Team-add dropdown switches from 7 mixed values (lead/associate/pa/of_counsel/local_counsel/expert/observer) to 4 responsibility-only values (lead/member/observer/external). Default 'member'. Closes m's bug — staffing a person no longer pretends to define their firm tier.
- Team table gains a Profession column (between Name and Responsibility), so the firm-tier badge is glanceable at staffing time.
- form.team-profession-hint surfaces the picked person's profession or warns when none is set ("kann keine 4-Augen-Genehmigungen erteilen").

projects-detail.ts:
- ProjectTeamMember type gains responsibility + user_profession. Legacy .role field kept readable for the deprecation window but UI no longer uses it.
- renderTeam renders 3-column tabular layout. Profession pill is read-only (.projekt-team-profession[--none]); responsibility is visible inline (inline-edit deferred to follow-up).
- canManagePartnerUnits switches from m.role==="lead" to m.responsibility==="lead".
- Team-add submit posts {responsibility} instead of {role}.

admin-team.tsx + client/admin-team.ts:
- New Profession column with inline-edit dropdown (6 values + "(extern)" NULL option). User type extends with profession?: string|null.
- Read-only cell uses .projekt-team-profession pill with "(extern)" placeholder for NULL.

onboarding.tsx + client/onboarding.ts:
- New required profession <select> with default 'associate'. Six values match the new enum. Hint copy explains the difference from job_title.
- POST /api/onboarding payload gains profession field.

i18n.ts: ~30 new keys DE+EN — projects.team.profession.* / .responsibility.* / projects.detail.team.col.profession / .responsibility / .form.responsibility / .form.profession.* / admin.team.col.profession.* / onboarding.profession.* / projects.team.profession.none + .hint variants.

CSS:
- .projekt-team-profession pill (firm-tier, read-only).
- .projekt-team-profession--none italic-dashed for NULL professions.
- .projekt-team-responsibility pill (per-project).
- .form-hint--warning for the team-add no-profession warning.

Build: bun build.ts clean (1723 i18n keys, all referenced). go build + go vet + go test (pure-Go) clean.
2026-05-07 21:56:18 +02:00

483 lines
18 KiB
TypeScript

import { initI18n, onLangChange, t, tDyn } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
additional_offices?: string[];
job_title: string | null;
// t-paliad-148: structured firm-tier (partner/of_counsel/associate/
// senior_pa/pa/paralegal). NULL = external. Editable via the
// admin-team Profession column.
profession?: string | null;
global_role: string;
lang: string;
reminder_morning_time?: string;
reminder_evening_time?: string;
reminder_timezone?: string;
created_at: string;
}
const PROFESSION_VALUES = [
"partner",
"of_counsel",
"associate",
"senior_pa",
"pa",
"paralegal",
];
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface Unonboarded {
id: string;
email: string;
created_at: string;
}
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
const JOB_TITLE_SUGGESTIONS = [
"Partner",
"Associate",
"PA",
"Of Counsel",
"Counsel",
"Counsel Knowledge Lawyer",
"Knowledge Lawyer",
"Referendar/in",
"Trainee",
"wiss. Mitarbeiter/in",
"Sekretariat",
];
let users: User[] = [];
let offices: Office[] = [];
let unonboarded: Unonboarded[] = [];
let activeOffice = "all";
let searchQuery = "";
let editingId: string | null = null;
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function officeLabel(key: string): string {
const o = offices.find((x) => x.key === key);
if (!o) return key;
return tDyn("office." + key) || (document.documentElement.lang === "en" ? o.label_en : o.label_de);
}
function fmtDate(iso: string): string {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString();
}
function permissionLabel(globalRole: string): string {
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
return t("admin.team.permission.standard") || "Standard";
}
async function loadAll() {
const [usersResp, officesResp] = await Promise.all([
fetch("/api/admin/users"),
fetch("/api/offices"),
]);
if (usersResp.status === 403) {
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
return;
}
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (officesResp.ok) offices = (await officesResp.json()) as Office[];
buildOfficeFilters();
render();
}
async function loadUnonboarded() {
const resp = await fetch("/api/admin/users/unonboarded");
if (!resp.ok) {
unonboarded = [];
return;
}
unonboarded = (await resp.json()) as Unonboarded[];
}
function presentOffices(): string[] {
const seen = new Set<string>();
for (const u of users) seen.add(u.office);
return OFFICE_ORDER.filter((k) => seen.has(k)).concat(
Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(),
);
}
function buildOfficeFilters() {
const container = document.getElementById("admin-team-office-filters")!;
const present = presentOffices();
const allBtn = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all") || "Alle")}</button>`;
const pills = present
.map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
.join("");
container.innerHTML = allBtn + pills;
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
btn.addEventListener("click", () => {
activeOffice = btn.dataset.office ?? "all";
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
btn.classList.add("active");
render();
});
});
}
function userMatchesSearch(u: User): boolean {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
u.display_name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.job_title ?? "").toLowerCase().includes(q)
);
}
function userMatchesOffice(u: User): boolean {
if (activeOffice === "all") return true;
if (u.office === activeOffice) return true;
return (u.additional_offices ?? []).includes(activeOffice);
}
function officeOptions(selected: string): string {
return offices
.map((o) => `<option value="${esc(o.key)}"${o.key === selected ? " selected" : ""}>${esc(officeLabel(o.key))}</option>`)
.join("");
}
function additionalOfficesEditor(selected: string[]): string {
return offices
.map((o) => {
const checked = selected.includes(o.key) ? " checked" : "";
return `<label class="admin-team-multi-opt"><input type="checkbox" data-additional="${esc(o.key)}"${checked} /> ${esc(officeLabel(o.key))}</label>`;
})
.join("");
}
function langOptions(selected: string): string {
return ["de", "en"]
.map((l) => `<option value="${l}"${l === selected ? " selected" : ""}>${l.toUpperCase()}</option>`)
.join("");
}
function globalAdminCount(): number {
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
}
function permissionCell(u: User): string {
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
}
function permissionEditor(u: User): string {
// Disable demoting the only remaining global_admin.
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
const disabled = isLastAdmin ? " disabled" : "";
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
}
function professionLabel(p: string | null | undefined): string {
if (!p) return "";
return tDyn(`projects.team.profession.${p}`) || p;
}
function professionCell(u: User): string {
if (!u.profession) {
return `<span class="admin-team-muted" title="${esc(t("admin.team.col.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis")}">${esc(t("projects.team.profession.none") || "(extern)")}</span>`;
}
return `<span class="projekt-team-profession">${esc(professionLabel(u.profession))}</span>`;
}
function professionEditor(u: User): string {
const noneOpt = `<option value=""${!u.profession ? " selected" : ""}>${esc(t("admin.team.col.profession.none") || "(extern)")}</option>`;
const opts = PROFESSION_VALUES.map(
(p) => `<option value="${esc(p)}"${u.profession === p ? " selected" : ""}>${esc(professionLabel(p))}</option>`,
).join("");
return `<select class="admin-team-input" data-field="profession">${noneOpt}${opts}</select>`;
}
function renderRow(u: User): string {
if (editingId === u.id) return renderEditRow(u);
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}">
<td class="entity-col-title">${esc(u.display_name)}</td>
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${professionCell(u)}</td>
<td>${permissionCell(u)}</td>
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
<td>${esc(u.lang.toUpperCase())}</td>
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-link admin-team-edit" data-id="${esc(u.id)}" data-i18n="admin.team.row.edit">Bearbeiten</button>
<button type="button" class="btn-link admin-team-delete" data-id="${esc(u.id)}" data-i18n="admin.team.row.delete">L&ouml;schen</button>
</td>
</tr>`;
}
function renderEditRow(u: User): string {
const additional = u.additional_offices ?? [];
const jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
const jobTitle = u.job_title ?? "";
return `
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
<td><span class="admin-team-muted" title="E-Mail kann nicht ge&auml;ndert werden">${esc(u.email)}</span></td>
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
<td>
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
</td>
<td>${professionEditor(u)}</td>
<td>${permissionEditor(u)}</td>
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
<td class="admin-team-actions-cell">
<button type="button" class="btn-primary admin-team-save" data-id="${esc(u.id)}" data-i18n="admin.team.row.save">Speichern</button>
<button type="button" class="btn-cancel admin-team-cancel" data-id="${esc(u.id)}" data-i18n="admin.team.row.cancel">Abbrechen</button>
</td>
</tr>`;
}
function showFeedback(msg: string, isError: boolean) {
const el = document.getElementById("admin-team-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() {
const tbody = document.getElementById("admin-team-tbody")!;
const empty = document.getElementById("admin-team-empty")!;
const count = document.getElementById("admin-team-count")!;
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesSearch(u));
count.textContent = `${filtered.length} / ${users.length}`;
if (filtered.length === 0) {
tbody.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
// Stable sort: global admins first, then by display_name.
const sorted = filtered.slice().sort((a, b) => {
const aAdmin = a.global_role === "global_admin";
const bAdmin = b.global_role === "global_admin";
if (aAdmin && !bAdmin) return -1;
if (bAdmin && !aAdmin) return 1;
return a.display_name.localeCompare(b.display_name);
});
tbody.innerHTML = sorted.map(renderRow).join("");
attachRowListeners();
}
function attachRowListeners() {
document.querySelectorAll<HTMLButtonElement>(".admin-team-edit").forEach((b) => {
b.addEventListener("click", () => {
editingId = b.dataset.id ?? null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-cancel").forEach((b) => {
b.addEventListener("click", () => {
editingId = null;
render();
});
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-save").forEach((b) => {
b.addEventListener("click", () => saveRow(b.dataset.id!));
});
document.querySelectorAll<HTMLButtonElement>(".admin-team-delete").forEach((b) => {
b.addEventListener("click", () => deleteRow(b.dataset.id!));
});
}
async function saveRow(id: string) {
const tr = document.querySelector<HTMLTableRowElement>(`tr[data-user-id="${id}"]`);
if (!tr) return;
const payload: Record<string, unknown> = {};
tr.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-field]").forEach((el) => {
payload[el.dataset.field!] = el.value;
});
const additional: string[] = [];
tr.querySelectorAll<HTMLInputElement>("[data-additional]").forEach((cb) => {
if (cb.checked) additional.push(cb.dataset.additional!);
});
payload.additional_offices = additional;
const resp = await fetch(`/api/admin/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Fehler beim Speichern.", true);
return;
}
const updated = (await resp.json()) as User;
users = users.map((u) => (u.id === id ? updated : u));
editingId = null;
showFeedback(t("admin.team.feedback.saved") || "Gespeichert.", false);
render();
}
async function deleteRow(id: string) {
const u = users.find((x) => x.id === id);
if (!u) return;
const confirmMsg = (t("admin.team.confirm.delete") || "{name} wirklich löschen?").replace("{name}", u.display_name);
if (!window.confirm(confirmMsg)) return;
const resp = await fetch(`/api/admin/users/${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;
}
users = users.filter((x) => x.id !== id);
showFeedback(t("admin.team.feedback.deleted") || "Gelöscht.", false);
render();
}
function initSearch() {
const input = document.getElementById("admin-team-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value;
render();
});
}
function openDirectAddModal() {
const modal = document.getElementById("admin-direct-add-modal")!;
const select = document.getElementById("admin-da-email") as HTMLSelectElement;
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const fb = document.getElementById("admin-da-feedback")!;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
fb.style.display = "none";
nameField.value = "";
jobTitleField.value = "";
officeSel.innerHTML = officeOptions("munich");
loadUnonboarded().then(() => {
select.innerHTML = `<option value="">${esc(t("admin.team.direct_add.email.placeholder") || "Bitte auswählen...")}</option>` +
unonboarded.map((u) => `<option value="${esc(u.email)}">${esc(u.email)}</option>`).join("");
if (unonboarded.length === 0) {
const noneMsg = t("admin.team.direct_add.empty") || "Keine offenen Konten.";
select.innerHTML = `<option value="">${esc(noneMsg)}</option>`;
}
});
modal.style.display = "flex";
}
function closeDirectAddModal() {
document.getElementById("admin-direct-add-modal")!.style.display = "none";
}
function initDirectAddModal() {
document.getElementById("admin-team-direct-add")!.addEventListener("click", openDirectAddModal);
document.getElementById("admin-direct-add-close")!.addEventListener("click", closeDirectAddModal);
document.getElementById("admin-da-cancel")!.addEventListener("click", closeDirectAddModal);
const emailSel = document.getElementById("admin-da-email") as HTMLSelectElement;
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
emailSel.addEventListener("change", () => {
if (!nameField.value && emailSel.value) {
// Pre-fill from email local-part.
const local = emailSel.value.split("@")[0] ?? "";
nameField.value = local
.split(/[._-]/)
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
.join(" ")
.trim();
}
});
document.getElementById("admin-direct-add-modal")!.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeDirectAddModal();
});
const form = document.getElementById("admin-direct-add-form") as HTMLFormElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
const fb = document.getElementById("admin-da-feedback")!;
fb.style.display = "none";
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
const payload: Record<string, unknown> = {
email: emailSel.value,
display_name: nameField.value.trim(),
office: officeSel.value,
job_title: jobTitleField.value.trim() || "Associate",
lang: "de",
};
const resp = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
fb.textContent = body.error || "Fehler.";
fb.className = "form-msg form-msg-error";
fb.style.display = "block";
return;
}
const created = (await resp.json()) as User;
users = users.concat(created);
closeDirectAddModal();
showFeedback(t("admin.team.feedback.added") || "Konto onboardet.", false);
render();
});
}
function initInviteButton() {
document.getElementById("admin-team-invite")!.addEventListener("click", () => {
document.getElementById("sidebar-invite-btn")?.click();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initDirectAddModal();
initInviteButton();
onLangChange(() => {
buildOfficeFilters();
render();
});
loadAll();
});