import { initI18n, onLangChange, t, tDyn } from "./i18n"; import { initSidebar } from "./sidebar"; import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast"; interface User { id: string; email: string; display_name: string; office: string; additional_offices?: string[]; job_title?: string | null; } interface MembershipEntry { user_id: string; project_ids: string[]; lead_project_ids: string[]; roles: string[]; } interface ProjectSummary { id: string; title: string; type: string; reference?: string | null; } interface MeUser { id: string; global_role: string; } interface DepartmentMember { user_id: string; email: string; display_name: string; office: string; job_title?: string | null; } interface Department { id: string; name: string; office: string; lead_user_id?: string; lead_display_name?: string; lead_email?: string; members: DepartmentMember[]; } 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 memberships: MembershipEntry[] = []; let projectsList: ProjectSummary[] = []; let me: MeUser | null = null; let groupBy: "office" | "department" = "office"; let activeOffice = "all"; let activeRole = "all"; let activeProjectIDs: Set = new Set(); let searchQuery = ""; // t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing // filter pills. When selection.size > 0 the sticky footer takes over the // broadcast action and targets only the explicit subset; with empty // selection the existing top-bar broadcast button still targets the whole // filter result (purely additive). // // Invariant: selection only ever holds user_ids that match the current // filter set — render() prunes drop-outs every cycle. This keeps the // counter honest and avoids "hidden-but-selected" debug nightmares. const selectedUserIDs: Set = new Set(); // For Shift-click range select — the user_id of the most recent toggle // in the currently-rendered list order. Reset to null on any filter // change so the range never spans an invisible row. let lastToggledUserID: string | null = null; // Snapshot of the rendered user-IDs in DOM order, refreshed on each render. // Drives Shift-click range expansion and the master-checkbox "select all // visible" action. let renderedUserIDs: string[] = []; const ICON_MAIL = ''; const ICON_PIN = ''; function esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } 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 "?"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } async function loadAll() { const [usersResp, deptsResp, membershipsResp, projectsResp, meResp] = await Promise.all([ fetch("/api/users"), fetch("/api/partner-units?include=members"), fetch("/api/team/memberships"), fetch("/api/projects"), fetch("/api/me"), ]); if (usersResp.ok) users = (await usersResp.json()) as User[]; if (deptsResp.ok) departments = (await deptsResp.json()) as Department[]; if (membershipsResp.ok) memberships = (await membershipsResp.json()) as MembershipEntry[]; if (projectsResp.ok) { const raw = (await projectsResp.json()) as ProjectSummary[]; projectsList = raw; } if (meResp.ok) me = (await meResp.json()) as MeUser; buildOfficeFilters(); buildRoleFilters(); buildProjectFilter(); render(); updateBroadcastButton(); } function presentOffices(): string[] { const seen = new Set(); for (const u of users) seen.add(u.office); return OFFICE_ORDER.filter((k) => seen.has(k)).concat( Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(), ); } function buildOfficeFilters() { const container = document.getElementById("team-office-filters")!; const offices = presentOffices(); const allBtn = ``; const pills = offices .map((k) => ``) .join(""); container.innerHTML = allBtn + pills; container.querySelectorAll(".filter-pill").forEach((btn) => { btn.addEventListener("click", () => { activeOffice = btn.dataset.office ?? "all"; container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); render(); }); }); } // 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.job_title ?? "", u.office, officeLabel(u.office), ...(u.additional_offices ?? []).map(officeLabel), ] .join(" ") .toLowerCase(); return hay.includes(q); } function userMatchesOffice(u: User): boolean { if (activeOffice === "all") return true; if (u.office === activeOffice) return true; return (u.additional_offices ?? []).includes(activeOffice); } function userMatchesRole(u: User): boolean { if (activeRole === "all") return true; return roleKey(u.job_title) === activeRole.toLowerCase(); } // userMatchesProject returns true when the project filter is empty or // when the user is a direct member of at least one selected project. // Inherited memberships intentionally don't qualify here — users want // "people I can mail on this matter", which means direct membership. function userMatchesProject(u: User): boolean { if (activeProjectIDs.size === 0) return true; const m = memberships.find((m) => m.user_id === u.id); if (!m) return false; for (const pid of m.project_ids) { if (activeProjectIDs.has(pid)) return true; } return false; } // canBroadcast reports whether the current user is allowed to send a // broadcast given the active project filter. global_admin always wins. // Otherwise the user must be a 'lead' on every project they have // selected (or, when no project is selected, on at least one of their // own projects). function canBroadcast(): boolean { if (!me) return false; if (me.global_role === "global_admin") return true; const myMembership = memberships.find((m) => m.user_id === me?.id); if (!myMembership || !myMembership.lead_project_ids.length) return false; if (activeProjectIDs.size === 0) { // No project filter — allow when caller leads at least one project. // Server-side check still runs per-broadcast so a non-lead can never // actually send. return true; } for (const pid of activeProjectIDs) { if (!myMembership.lead_project_ids.includes(pid)) return false; } return true; } function buildProjectFilter() { const container = document.getElementById("team-project-filter"); if (!container) return; // Show only projects the caller can see — projectsList already does // that via the visibility-gated /api/projects endpoint. const sortedProjects = [...projectsList].sort((a, b) => (a.title || "").localeCompare(b.title || ""), ); const options = sortedProjects .map( (p) => ``, ) .join(""); const summary = activeProjectIDs.size === 0 ? (t("team.filter.project.all") || "Alle Projekte") : `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`; container.innerHTML = ` `; const trigger = container.querySelector("[data-project-trigger]"); const panel = container.querySelector("[data-project-panel]"); trigger?.addEventListener("click", (e) => { e.stopPropagation(); panel?.classList.toggle("hidden"); }); document.addEventListener("click", (e) => { if (!container.contains(e.target as Node)) panel?.classList.add("hidden"); }); container.querySelectorAll("input[data-project-id]").forEach((cb) => { cb.addEventListener("change", () => { const pid = cb.dataset.projectId!; if (cb.checked) activeProjectIDs.add(pid); else activeProjectIDs.delete(pid); buildProjectFilter(); render(); updateBroadcastButton(); }); }); container.querySelector("[data-project-clear]")?.addEventListener("click", () => { activeProjectIDs.clear(); buildProjectFilter(); render(); updateBroadcastButton(); }); } function buildBroadcastButton() { const wrap = document.getElementById("team-broadcast-wrap"); if (!wrap) return; // Wait for /api/me so the affordance never flickers between admin (form) // and non-admin (mailto) on initial paint. canBroadcast() already returns // false when me is null but we'd briefly render the mailto anchor before // the admin form, which is visually jarring. if (!me) { wrap.innerHTML = ""; wrap.style.display = "none"; return; } wrap.style.display = ""; const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl"); const counter = `0`; if (canBroadcast()) { // Admin path (global_admin or project-lead-of-selected): opens the // in-app compose modal that POSTs to /api/team/broadcast. wrap.innerHTML = ` `; document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick()); } else { // Non-admin path (t-paliad-244): native mailto: anchor pre-filled with // the current filter set. href is refreshed in updateBroadcastButton() // whenever filters change so the link always reflects what's visible. wrap.innerHTML = ` ${label} ${counter} `; } } function updateBroadcastButton() { buildBroadcastButton(); const recipients = displayedRecipients(); const countEl = document.getElementById("team-broadcast-count"); if (countEl) countEl.textContent = String(recipients.length); const btn = document.getElementById("team-broadcast-btn"); if (!btn) return; if (btn.tagName === "BUTTON") { (btn as HTMLButtonElement).disabled = recipients.length === 0; } else { // Anchor (non-admin): regenerate the mailto: href against the current // visible recipients, and disable the affordance when empty so a click // doesn't open an empty mail composer. const a = btn as HTMLAnchorElement; if (recipients.length === 0) { a.setAttribute("href", "mailto:"); a.setAttribute("aria-disabled", "true"); a.style.pointerEvents = "none"; a.style.opacity = "0.5"; } else { a.setAttribute("href", buildMailtoHref(recipients)); a.removeAttribute("aria-disabled"); a.style.pointerEvents = ""; a.style.opacity = ""; } } } // displayedRecipients returns the currently visible users as broadcast // recipients. Personal placeholder fields are sourced from each user // (display_name / first_name) and from the membership index when a // project filter is set (role_on_project = the role on the selected // project; falls back to first available role). function displayedRecipients(): BroadcastRecipient[] { const filtered = users.filter( (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u), ); return filtered.map((u) => { const m = memberships.find((m) => m.user_id === u.id); let role = ""; if (m) { if (activeProjectIDs.size > 0) { const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid)); if (idx >= 0) role = m.roles[idx]; } else if (m.roles.length > 0) { role = m.roles[0]; } } return { user_id: u.id, email: u.email, display_name: u.display_name, first_name: firstName(u.display_name), role_on_project: role, }; }); } function onBroadcastClick() { const recipients = displayedRecipients(); const selectedProjectIDs = Array.from(activeProjectIDs); // When exactly one project is selected we pass it as project_id so // the backend can verify lead-ship on that project. With multi- // select we leave project_id null and rely on global_admin (the // service rejects non-admin senders without a project_id). const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null; const offices = activeOffice === "all" ? [] : [activeOffice]; const roles = activeRole === "all" ? [] : [activeRole]; openBroadcastModal({ recipients, projectID, projectIDs: selectedProjectIDs, offices, roles, }); } 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(); // t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a // click on the checkbox cell triggers the toggle; the rest of the card // (links, email, etc.) keeps its native behaviour. Selection state // mirrored to data-selected so the CSS can highlight the card. const selected = selectedUserIDs.has(u.id); const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen"; return `
${esc(u.display_name)}
${jobTitle ? `
${esc(roleLabel(jobTitle))}
` : ""}
${ICON_PIN}${esc(officeLabel(u.office))} ${additional.length ? `+ ${additional.map((o) => esc(officeLabel(o))).join(", ")}` : ""}
${ICON_MAIL}${esc(u.email)}
`; } // escAttr is the attribute-context counterpart of esc. Used in title="" // + aria-label="" where esc()'s div-textContent trick is fine but // double-quote-escaping is the bit we actually need. function escAttr(s: string): string { return esc(s).replace(/"/g, """); } function renderGroupByOffice(filtered: User[]): string { const present = presentOffices(); const sections = present .map((officeKey) => { const inOffice = filtered.filter((u) => u.office === officeKey || (u.additional_offices ?? []).includes(officeKey)); if (!inOffice.length) return ""; return `

${esc(officeLabel(officeKey))}

${inOffice.length}
${inOffice.map(renderUserCard).join("")}
`; }) .filter(Boolean); // Catch users whose office key isn't in OFFICE_ORDER and isn't present-listed. const placedKeys = new Set(present); const orphans = filtered.filter((u) => !placedKeys.has(u.office)); if (orphans.length) { sections.push(`

${esc(t("team.group.other") || "Sonstige")}

${orphans.length}
${orphans.map(renderUserCard).join("")}
`); } return sections.join(""); } function renderGroupByDepartment(filtered: User[]): string { const filteredIDs = new Set(filtered.map((u) => u.id)); const sections: string[] = []; for (const d of departments) { if (activeOffice !== "all" && d.office !== activeOffice) continue; const visibleMembers = d.members .map(memberAsUser) .filter((u): u is User => !!u && filteredIDs.has(u.id)); if (!visibleMembers.length && !d.lead_display_name) continue; const leadHTML = d.lead_display_name ? `
${esc(t("team.dept.lead") || "Lead")}: ${esc(d.lead_display_name)}${d.lead_email ? ` ${esc(d.lead_email)}` : ""}
` : ""; sections.push(`

${esc(d.name)}

${esc(officeLabel(d.office))}
${leadHTML}
${visibleMembers.length}
${visibleMembers.map(renderUserCard).join("")}
`); } // Users not in any partner unit get a single "ohne Partner Unit" bucket so // they're still discoverable in the directory. const inAnyDept = new Set(); for (const d of departments) for (const m of d.members) inAnyDept.add(m.user_id); const orphans = filtered.filter((u) => !inAnyDept.has(u.id)); if (orphans.length) { const heading = t("team.partner_unit.unassigned") || "Ohne Partner Unit"; sections.push(`

${esc(heading)}

${orphans.length}
${orphans.map(renderUserCard).join("")}
`); } return sections.join(""); } function render() { const list = document.getElementById("team-list")!; const empty = document.getElementById("team-empty")!; const count = document.getElementById("team-count")!; const filtered = users.filter( (u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u), ); // t-paliad-223 (#53): prune drop-outs from the explicit selection. The // invariant is "selection ⊆ visible"; carrying invisible IDs forward // would create stale "12 selected" counters that don't match what the // user sees on screen. pruneSelectionToVisible(new Set(filtered.map((u) => u.id))); count.textContent = `${filtered.length} / ${users.length}`; updateBroadcastButton(); if (filtered.length === 0) { list.innerHTML = ""; empty.style.display = "block"; renderedUserIDs = []; syncMasterCheckbox(); renderSelectionFooter(); return; } empty.style.display = "none"; list.innerHTML = groupBy === "office" ? renderGroupByOffice(filtered) : renderGroupByDepartment(filtered); // Refresh the DOM-order snapshot Shift-click + master-checkbox rely on. renderedUserIDs = Array.from( list.querySelectorAll(".team-card"), ).map((el) => el.dataset.userId || ""); wireSelectionCheckboxes(list); syncMasterCheckbox(); renderSelectionFooter(); } // pruneSelectionToVisible drops user_ids from selection that no longer // match the visible set. Always called from render() before painting so // the per-row "checked" state and the footer counter stay in sync. function pruneSelectionToVisible(visible: Set): void { const removed: string[] = []; for (const id of selectedUserIDs) { if (!visible.has(id)) removed.push(id); } for (const id of removed) selectedUserIDs.delete(id); if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) { lastToggledUserID = null; } } // wireSelectionCheckboxes attaches click handlers to every per-row // checkbox in the freshly-rendered list. Each click toggles the // underlying selection Set + the data-selected attribute on the card. // Shift-click extends a contiguous range from the previous toggle to // the current row using renderedUserIDs as the order reference. function wireSelectionCheckboxes(list: HTMLElement): void { list.querySelectorAll(".team-card-select-input").forEach((cb) => { cb.addEventListener("click", (ev) => { const id = cb.dataset.userId || ""; if (!id) return; const checked = cb.checked; if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) { applyRangeSelection(lastToggledUserID, id, checked); } else { if (checked) selectedUserIDs.add(id); else selectedUserIDs.delete(id); } lastToggledUserID = id; // Visual + footer refresh without a full re-render (selection // changes don't affect the filter set; render() is reserved for // filter/data changes to keep typing in the search box fast). refreshCardSelectedAttribute(); syncMasterCheckbox(); renderSelectionFooter(); }); }); } // applyRangeSelection sets selection state for every user between // (inclusive) startID and endID in renderedUserIDs order. Mode = the // final state — checked => add to selection, unchecked => remove. function applyRangeSelection(startID: string, endID: string, mode: boolean): void { const a = renderedUserIDs.indexOf(startID); const b = renderedUserIDs.indexOf(endID); if (a === -1 || b === -1) { // One of the anchors dropped out of the current visible set; fall // back to a single-row toggle of the end-id. if (mode) selectedUserIDs.add(endID); else selectedUserIDs.delete(endID); return; } const [lo, hi] = a <= b ? [a, b] : [b, a]; for (let i = lo; i <= hi; i++) { const id = renderedUserIDs[i]; if (mode) selectedUserIDs.add(id); else selectedUserIDs.delete(id); } } // refreshCardSelectedAttribute syncs every visible card's data-selected // + checkbox.checked to the canonical Set, without a full re-render. function refreshCardSelectedAttribute(): void { const list = document.getElementById("team-list"); if (!list) return; list.querySelectorAll(".team-card").forEach((card) => { const id = card.dataset.userId || ""; const selected = selectedUserIDs.has(id); card.dataset.selected = selected ? "true" : "false"; const cb = card.querySelector(".team-card-select-input"); if (cb) cb.checked = selected; }); } // renderSelectionFooter mounts (or hides) the sticky footer that takes // over the broadcast action when ≥ 1 row is checked. The footer lives // outside the main content tree so it can be position: fixed without // fighting any of the existing layout rules. function renderSelectionFooter(): void { let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null; const n = selectedUserIDs.size; if (n === 0) { if (footer) footer.style.display = "none"; document.body.classList.remove("team-has-selection"); return; } if (!footer) { footer = document.createElement("div"); footer.id = "team-selection-footer"; footer.className = "team-selection-footer"; document.body.appendChild(footer); } const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace( "{n}", String(n), ); const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl"); // t-paliad-244: mirror buildBroadcastButton() so the bottom send button // behaves the same as the filter-bar one. Admin (canBroadcast) opens the // compose modal; non-admin gets a native mailto: anchor pre-filled with // the explicit selection. const adminPath = canBroadcast(); const sendAction = adminPath ? `` : `${sendLabel}`; footer.innerHTML = ` ${esc(countLabel)} ${sendAction} `; footer.style.display = ""; document.body.classList.add("team-has-selection"); document.getElementById("team-selection-clear")?.addEventListener("click", () => { selectedUserIDs.clear(); lastToggledUserID = null; refreshCardSelectedAttribute(); syncMasterCheckbox(); renderSelectionFooter(); }); if (adminPath) { document.getElementById("team-selection-send")?.addEventListener("click", () => { onBroadcastFromSelection(); }); } // Anchor path has no click handler — native href open is the action. } // selectedRecipients maps the explicit selection Set into the // BroadcastRecipient shape openBroadcastModal expects. Mirrors the // role-resolution rules of displayedRecipients() (active project // filter wins; falls back to first available role). function selectedRecipients(): BroadcastRecipient[] { const out: BroadcastRecipient[] = []; for (const id of selectedUserIDs) { const u = users.find((u) => u.id === id); if (!u) continue; const m = memberships.find((m) => m.user_id === u.id); let role = ""; if (m) { if (activeProjectIDs.size > 0) { const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid)); if (idx >= 0) role = m.roles[idx]; } else if (m.roles.length > 0) { role = m.roles[0]; } } out.push({ user_id: u.id, email: u.email, display_name: u.display_name, first_name: firstName(u.display_name), role_on_project: role, }); } return out; } function onBroadcastFromSelection(): void { const recipients = selectedRecipients(); if (recipients.length === 0) return; const selectedProjectIDs = Array.from(activeProjectIDs); // Same scope-resolution as displayedRecipients/onBroadcastClick: pass // project_id only when exactly one is selected so the server can // verify lead-ship; multi-project relies on global_admin. const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null; const offices = activeOffice === "all" ? [] : [activeOffice]; const roles = activeRole === "all" ? [] : [activeRole]; openBroadcastModal({ recipients, projectID, projectIDs: selectedProjectIDs, offices, roles, }); } // syncMasterCheckbox refreshes the master "select all visible" checkbox // to one of three states: empty / partial / full. The HTML element lives // in team.tsx (#team-select-master); when missing (older shells) the // helper no-ops so the page still works. function syncMasterCheckbox(): void { const master = document.getElementById("team-select-master") as HTMLInputElement | null; if (!master) return; const visible = renderedUserIDs.length; if (visible === 0) { master.checked = false; master.indeterminate = false; master.disabled = true; return; } master.disabled = false; let selectedHere = 0; for (const id of renderedUserIDs) { if (selectedUserIDs.has(id)) selectedHere++; } master.checked = selectedHere === visible; master.indeterminate = selectedHere > 0 && selectedHere < visible; } function onMasterToggle(): void { const master = document.getElementById("team-select-master") as HTMLInputElement | null; if (!master) return; const checked = master.checked; for (const id of renderedUserIDs) { if (checked) selectedUserIDs.add(id); else selectedUserIDs.delete(id); } lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null; refreshCardSelectedAttribute(); syncMasterCheckbox(); renderSelectionFooter(); } function initToggle() { const container = document.querySelector(".team-toggle")!; container.addEventListener("click", (e) => { const btn = (e.target as HTMLElement).closest(".filter-pill"); if (!btn) return; const next = btn.dataset.group as "office" | "department" | undefined; if (!next || next === groupBy) return; groupBy = next; container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active")); btn.classList.add("active"); render(); }); } function initSearch() { const input = document.getElementById("team-search") as HTMLInputElement; input.addEventListener("input", () => { searchQuery = input.value; render(); }); } document.addEventListener("DOMContentLoaded", () => { initI18n(); initSidebar(); initSearch(); initToggle(); // t-paliad-223 (#53): master checkbox toggles every visible row. document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle); onLangChange(() => { buildOfficeFilters(); buildRoleFilters(); render(); }); loadAll(); });