Files
paliad/frontend/src/client/onboarding.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

185 lines
5.5 KiB
TypeScript

import { initI18n, onLangChange, getLang, t } from "./i18n";
interface Office {
key: string;
label_de: string;
label_en: string;
}
interface PartnerUnit {
id: string;
name: string;
office: string;
}
let offices: Office[] = [];
let partnerUnits: PartnerUnit[] = [];
async function seedDisplayName(): Promise<void> {
const input = document.getElementById("onb-display-name") as HTMLInputElement;
if (!input || input.value) return;
// /api/me returns 404 for users without a paliad.users row, but includes
// the JWT email so we can pre-fill the display name from the local-part
// (e.g. "m.flexsiebels" from "m.flexsiebels@hlc.com"). Best-effort.
try {
const resp = await fetch("/api/me");
if (resp.status !== 404) return;
const body = await resp.json().catch(() => null);
const email = body && typeof body.email === "string" ? body.email : "";
const localPart = email.split("@")[0] || "";
if (localPart) input.value = localPart;
} catch {
/* non-fatal */
}
}
async function loadOffices(): Promise<void> {
try {
const resp = await fetch("/api/offices");
if (!resp.ok) return;
offices = await resp.json();
} catch {
offices = [];
}
renderOfficeOptions();
}
async function loadPartnerUnits(): Promise<void> {
try {
const resp = await fetch("/api/partner-units");
if (!resp.ok) return;
partnerUnits = (await resp.json()) as PartnerUnit[];
} catch {
partnerUnits = [];
}
renderPartnerUnitOptions();
}
function renderPartnerUnitOptions(): void {
const select = document.getElementById("onb-partner-unit") as HTMLSelectElement | null;
if (!select) return;
const previous = select.value;
const unassignedLabel = t("onboarding.partner_unit.unassigned") || "(noch keine Zuordnung)";
const head = `<option value="">${esc(unassignedLabel)}</option>`;
select.innerHTML =
head +
partnerUnits
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
.join("");
// Preserve a previous selection across language toggles (which re-render
// the head option).
if (previous && partnerUnits.some((u) => u.id === previous)) {
select.value = previous;
}
}
function renderOfficeOptions(): void {
const select = document.getElementById("onb-office") as HTMLSelectElement | null;
if (!select) return;
const isEN = getLang() === "en";
const previous = select.value;
const placeholder = `<option value="" disabled selected>${esc(t("onboarding.office.placeholder"))}</option>`;
select.innerHTML =
placeholder +
offices
.map((o) => {
const label = isEN ? o.label_en : o.label_de;
return `<option value="${esc(o.key)}">${esc(label)}</option>`;
})
.join("");
if (previous && offices.some((o) => o.key === previous)) {
select.value = previous;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function clearMessages(): void {
document.querySelectorAll(".login-error, .login-success").forEach((el) => el.remove());
}
function showMessage(msg: string, cls: "login-error" | "login-success"): void {
clearMessages();
const div = document.createElement("div");
div.className = cls;
div.textContent = msg;
const heading = document.querySelector(".onboarding-lede");
if (heading) heading.after(div);
}
async function submitForm(e: Event): Promise<void> {
e.preventDefault();
clearMessages();
const form = document.getElementById("onboarding-form") as HTMLFormElement;
const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]')!;
const data = new FormData(form);
const displayName = (data.get("display_name") as string || "").trim();
const office = (data.get("office") as string || "").trim();
const jobTitle = (data.get("job_title") as string || "").trim();
const profession = (data.get("profession") as string || "").trim();
const partnerUnitID = (data.get("partner_unit_id") as string || "").trim();
if (!displayName) {
showMessage(t("onboarding.error.display_name"), "login-error");
return;
}
if (!office) {
showMessage(t("onboarding.error.office"), "login-error");
return;
}
if (!jobTitle) {
showMessage(t("onboarding.error.job_title"), "login-error");
return;
}
const payload: Record<string, unknown> = {
display_name: displayName,
office,
job_title: jobTitle,
profession,
};
if (partnerUnitID) payload.partner_unit_id = partnerUnitID;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
window.location.href = "/dashboard";
return;
}
if (resp.status === 409) {
// Row already exists — user is onboarded. Push them forward.
window.location.href = "/dashboard";
return;
}
const body = await resp.json().catch(() => ({}) as { error?: string });
showMessage(body.error || t("onboarding.error.generic"), "login-error");
submitBtn.disabled = false;
} catch {
showMessage(t("onboarding.error.connection"), "login-error");
submitBtn.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
seedDisplayName();
document.getElementById("onboarding-form")!.addEventListener("submit", submitForm);
loadOffices();
loadPartnerUnits();
onLangChange(() => {
renderOfficeOptions();
renderPartnerUnitOptions();
});
});