feat(t-paliad-085): /team — Role filter pill row

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.<slug> 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)
This commit is contained in:
m
2026-04-30 10:45:15 +02:00
parent b1bdf8ceb3
commit 2c4e1e5782
4 changed files with 121 additions and 6 deletions

View File

@@ -1142,6 +1142,17 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
@@ -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.<slug>; 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<string, string>();
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 = `<button class="filter-pill${activeRole === "all" ? " active" : ""}" data-role="all" type="button">${esc(t("team.filter.all"))}</button>`;
const pills = roles
.map((r) => `<button class="filter-pill${activeKey === r.toLowerCase() ? " active" : ""}" data-role="${esc(r)}" type="button">${esc(roleLabel(r))}</button>`)
.join("");
container.innerHTML = allBtn + pills;
container.querySelectorAll<HTMLButtonElement>(".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 `
<article class="team-card">
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
<div class="team-card-body">
<div class="team-card-name">${esc(u.display_name)}</div>
${u.role ? `<div class="team-card-role">${esc(u.role)}</div>` : ""}
${jobTitle ? `<div class="team-card-role">${esc(roleLabel(jobTitle))}</div>` : ""}
<div class="team-card-meta">
<span class="team-office-badge">${ICON_PIN}<span>${esc(officeLabel(u.office))}</span></span>
${additional.length ? `<span class="team-office-extra">+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}</span>` : ""}
@@ -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();

View File

@@ -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"

View File

@@ -60,10 +60,14 @@ export function renderTeam(): string {
</div>
</div>
<div className="team-filter-row" id="team-office-filters">
<div className="team-filter-row" id="team-office-filters" aria-label="Standort">
<button className="filter-pill active" data-office="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
<div className="team-filter-row" id="team-role-filters" aria-label="Rolle">
<button className="filter-pill active" data-role="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">