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:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user