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.
483 lines
18 KiB
TypeScript
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ö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ä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();
|
|
});
|