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.
185 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|