From 2c4e1e5782206484925f9913d578f54079a67add Mon Sep 17 00:00:00 2001 From: m Date: Thu, 30 Apr 2026 10:45:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-085):=20/team=20=E2=80=94=20Role?= =?UTF-8?q?=20filter=20pill=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Role filter row alongside the existing Office row on /team. Pills are rendered from the distinct paliad.users.job_title values present in the loaded users; "Alle" + Partner / Counsel / Senior Associate / Associate / … / PA / Paralegal in seniority order, anything unrecognised sorted alphabetically after. The interface field name was previously `role: string`, left over from before t-paliad-051 split paliad.users.role into job_title + global_role. The API has been returning `job_title` since then, so the role line on every card was silently empty. Updated User / DepartmentMember interfaces to `job_title?: string | null`, and renderUserCard now displays it via roleLabel(). Search now matches job_title too. Role values are normalised case-insensitively (DB still has both "Associate" and "associate" today — separate cleanup), and a roleLabel() helper looks up team.role. with the raw job_title as fallback so new titles render even before the i18n entry exists. Files - frontend/src/team.tsx — second team-filter-row - frontend/src/client/team.ts — User.job_title, ROLE_ORDER, presentRoles, buildRoleFilters, userMatchesRole, roleLabel; render() intersects office × role × search - frontend/src/client/i18n.ts — team.filter.role + 10 team.role.* keys (DE/EN) --- frontend/src/client/i18n.ts | 22 ++++++++++ frontend/src/client/team.ts | 88 ++++++++++++++++++++++++++++++++++--- frontend/src/i18n-keys.ts | 11 +++++ frontend/src/team.tsx | 6 ++- 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index a8809ef..4d3d76a 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1142,6 +1142,17 @@ const translations: Record> = { "team.group.department": "Nach Partner Unit", "team.group.other": "Sonstige", "team.filter.all": "Alle", + "team.filter.role": "Rolle", + "team.role.partner": "Partner", + "team.role.counsel": "Counsel", + "team.role.counsel_knowledge_lawyer": "Counsel Knowledge Lawyer", + "team.role.senior_associate": "Senior Associate", + "team.role.associate": "Associate", + "team.role.junior_associate": "Junior Associate", + "team.role.trainee": "Trainee", + "team.role.pa": "PA", + "team.role.paralegal": "Paralegal", + "team.role.secretary": "Sekretär:in", "team.empty": "Keine Treffer.", "team.dept.lead": "Lead", "team.dept.unassigned": "Ohne Partner Unit", @@ -2456,6 +2467,17 @@ const translations: Record> = { "team.group.department": "By partner unit", "team.group.other": "Other", "team.filter.all": "All", + "team.filter.role": "Role", + "team.role.partner": "Partner", + "team.role.counsel": "Counsel", + "team.role.counsel_knowledge_lawyer": "Counsel Knowledge Lawyer", + "team.role.senior_associate": "Senior Associate", + "team.role.associate": "Associate", + "team.role.junior_associate": "Junior Associate", + "team.role.trainee": "Trainee", + "team.role.pa": "PA", + "team.role.paralegal": "Paralegal", + "team.role.secretary": "Secretary", "team.empty": "No matches.", "team.dept.lead": "Lead", "team.dept.unassigned": "No partner unit", diff --git a/frontend/src/client/team.ts b/frontend/src/client/team.ts index 1412112..f36c0ae 100644 --- a/frontend/src/client/team.ts +++ b/frontend/src/client/team.ts @@ -7,7 +7,7 @@ interface User { display_name: string; office: string; additional_offices?: string[]; - role: string; + job_title?: string | null; } interface DepartmentMember { @@ -15,7 +15,7 @@ interface DepartmentMember { email: string; display_name: string; office: string; - role: string; + job_title?: string | null; } interface Department { @@ -30,10 +30,27 @@ interface Department { const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"]; +// ROLE_ORDER drives pill ordering: seniority hierarchy first, then alphabetical +// for anything new the firm starts using. Compared case-insensitively against +// the trimmed job_title — see roleKey(). +const ROLE_ORDER = [ + "partner", + "counsel", + "counsel knowledge lawyer", + "senior associate", + "associate", + "junior associate", + "trainee", + "pa", + "paralegal", + "secretary", +]; + let users: User[] = []; let departments: Department[] = []; let groupBy: "office" | "department" = "office"; let activeOffice = "all"; +let activeRole = "all"; let searchQuery = ""; const ICON_MAIL = ''; @@ -49,6 +66,19 @@ function officeLabel(key: string): string { return tDyn("office." + key) || key; } +// roleKey normalises a free-text job_title for case-insensitive comparison +// (DB has both "Associate" and "associate" today — t-paliad-085). +function roleKey(jobTitle: string | null | undefined): string { + return (jobTitle ?? "").trim().toLowerCase(); +} + +// roleLabel looks up a translation under team.role.; falls back to the +// raw job_title so newly-introduced titles render even without an i18n entry. +function roleLabel(jobTitle: string): string { + const slug = roleKey(jobTitle).replace(/\s+/g, "_"); + return tDyn("team.role." + slug) || jobTitle; +} + function initials(name: string): string { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; @@ -64,6 +94,7 @@ async function loadAll() { if (usersResp.ok) users = (await usersResp.json()) as User[]; if (deptsResp.ok) departments = (await deptsResp.json()) as Department[]; buildOfficeFilters(); + buildRoleFilters(); render(); } @@ -93,13 +124,53 @@ function buildOfficeFilters() { }); } +// presentRoles returns the distinct job_titles in the loaded users, ordered by +// ROLE_ORDER (seniority) with anything unrecognised tacked on alphabetically. +// Values are case-folded for grouping; the first-seen casing wins for display. +function presentRoles(): string[] { + const seen = new Map(); + for (const u of users) { + const jt = (u.job_title ?? "").trim(); + if (!jt) continue; + const key = jt.toLowerCase(); + if (!seen.has(key)) seen.set(key, jt); + } + return [...seen.values()].sort((a, b) => { + const ai = ROLE_ORDER.indexOf(a.toLowerCase()); + const bi = ROLE_ORDER.indexOf(b.toLowerCase()); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return a.localeCompare(b); + }); +} + +function buildRoleFilters() { + const container = document.getElementById("team-role-filters")!; + const roles = presentRoles(); + const activeKey = activeRole.toLowerCase(); + const allBtn = ``; + const pills = roles + .map((r) => ``) + .join(""); + container.innerHTML = allBtn + pills; + container.querySelectorAll(".filter-pill").forEach((btn) => { + btn.addEventListener("click", () => { + activeRole = btn.dataset.role ?? "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(); const hay = [ u.display_name, u.email, - u.role, + u.job_title ?? "", u.office, officeLabel(u.office), ...(u.additional_offices ?? []).map(officeLabel), @@ -115,18 +186,24 @@ function userMatchesOffice(u: User): boolean { return (u.additional_offices ?? []).includes(activeOffice); } +function userMatchesRole(u: User): boolean { + if (activeRole === "all") return true; + return roleKey(u.job_title) === activeRole.toLowerCase(); +} + function memberAsUser(m: DepartmentMember): User | undefined { return users.find((u) => u.id === m.user_id); } function renderUserCard(u: User): string { const additional = (u.additional_offices ?? []).filter((o) => o !== u.office); + const jobTitle = (u.job_title ?? "").trim(); return `
${esc(u.display_name)}
- ${u.role ? `
${esc(u.role)}
` : ""} + ${jobTitle ? `
${esc(roleLabel(jobTitle))}
` : ""}
${ICON_PIN}${esc(officeLabel(u.office))} ${additional.length ? `+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}` : ""} @@ -220,7 +297,7 @@ function render() { const empty = document.getElementById("team-empty")!; const count = document.getElementById("team-count")!; - const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesSearch(u)); + const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesSearch(u)); count.textContent = `${filtered.length} / ${users.length}`; if (filtered.length === 0) { @@ -264,6 +341,7 @@ document.addEventListener("DOMContentLoaded", () => { initToggle(); onLangChange(() => { buildOfficeFilters(); + buildRoleFilters(); render(); }); loadAll(); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index ac03e56..aec76a4 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1219,11 +1219,22 @@ export type I18nKey = | "team.dept.unassigned" | "team.empty" | "team.filter.all" + | "team.filter.role" | "team.group.department" | "team.group.office" | "team.group.other" | "team.heading" | "team.partner_unit.unassigned" + | "team.role.associate" + | "team.role.counsel" + | "team.role.counsel_knowledge_lawyer" + | "team.role.junior_associate" + | "team.role.pa" + | "team.role.paralegal" + | "team.role.partner" + | "team.role.secretary" + | "team.role.senior_associate" + | "team.role.trainee" | "team.search.placeholder" | "team.subtitle" | "team.title" diff --git a/frontend/src/team.tsx b/frontend/src/team.tsx index ef4e125..0bd5ff1 100644 --- a/frontend/src/team.tsx +++ b/frontend/src/team.tsx @@ -60,10 +60,14 @@ export function renderTeam(): string {
-
+
+
+ +
+