Files
paliad/frontend/src/client/team.ts
mAi 5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00

867 lines
32 KiB
TypeScript

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<string> = 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<string> = 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 = '<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>';
const ICON_PIN = '<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="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
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.<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 "?";
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<string>();
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 = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all"))}</button>`;
const pills = offices
.map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
.join("");
container.innerHTML = allBtn + pills;
container.querySelectorAll<HTMLButtonElement>(".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<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.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) =>
`<label class="filter-checkbox"><input type="checkbox" data-project-id="${esc(p.id)}" ${
activeProjectIDs.has(p.id) ? "checked" : ""
} /> <span>${esc(p.title)}</span></label>`,
)
.join("");
const summary = activeProjectIDs.size === 0
? (t("team.filter.project.all") || "Alle Projekte")
: `${activeProjectIDs.size} ${t("team.filter.project.selected") || "ausgewählt"}`;
container.innerHTML = `
<button type="button" class="filter-pill team-project-trigger" data-project-trigger>
<span class="team-project-summary">${esc(t("team.filter.project") || "Projekt")}: ${esc(summary)}</span>
</button>
<div class="team-project-panel hidden" data-project-panel>
<div class="team-project-actions">
<button type="button" class="link-button" data-project-clear>${esc(t("team.filter.project.clear") || "Alle abwählen")}</button>
</div>
<div class="team-project-options">${options}</div>
</div>
`;
const trigger = container.querySelector<HTMLButtonElement>("[data-project-trigger]");
const panel = container.querySelector<HTMLDivElement>("[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<HTMLInputElement>("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<HTMLButtonElement>("[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 = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
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 = `
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
${label} ${counter}
</button>
`;
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 = `
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
${label} ${counter}
</a>
`;
}
}
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 `
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
<label class="team-card-select" title="${escAttr(selectAria)}">
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
</label>
<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>
${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>` : ""}
</div>
<a class="team-card-email" href="mailto:${esc(u.email)}">${ICON_MAIL}<span>${esc(u.email)}</span></a>
</div>
</article>`;
}
// 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, "&quot;");
}
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 `
<section class="team-group">
<header class="team-group-header">
<h2>${esc(officeLabel(officeKey))}</h2>
<span class="team-group-count">${inOffice.length}</span>
</header>
<div class="team-grid">${inOffice.map(renderUserCard).join("")}</div>
</section>`;
})
.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(`
<section class="team-group">
<header class="team-group-header">
<h2>${esc(t("team.group.other") || "Sonstige")}</h2>
<span class="team-group-count">${orphans.length}</span>
</header>
<div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
</section>`);
}
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
? `<div class="team-dept-lead">${esc(t("team.dept.lead") || "Lead")}: <strong>${esc(d.lead_display_name)}</strong>${d.lead_email ? ` <a href="mailto:${esc(d.lead_email)}">${esc(d.lead_email)}</a>` : ""}</div>`
: "";
sections.push(`
<section class="team-group">
<header class="team-group-header">
<div>
<h2>${esc(d.name)}</h2>
<div class="team-group-sub">${esc(officeLabel(d.office))}</div>
${leadHTML}
</div>
<span class="team-group-count">${visibleMembers.length}</span>
</header>
<div class="team-grid">${visibleMembers.map(renderUserCard).join("")}</div>
</section>`);
}
// 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<string>();
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(`
<section class="team-group team-group-loose">
<header class="team-group-header">
<h2>${esc(heading)}</h2>
<span class="team-group-count">${orphans.length}</span>
</header>
<div class="team-grid">${orphans.map(renderUserCard).join("")}</div>
</section>`);
}
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<HTMLElement>(".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<string>): 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<HTMLInputElement>(".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<HTMLElement>(".team-card").forEach((card) => {
const id = card.dataset.userId || "";
const selected = selectedUserIDs.has(id);
card.dataset.selected = selected ? "true" : "false";
const cb = card.querySelector<HTMLInputElement>(".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
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
footer.innerHTML = `
<span class="team-selection-count">${esc(countLabel)}</span>
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
${esc(t("team.selection.clear") || "Auswahl aufheben")}
</button>
${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<HTMLElement>(".team-toggle")!;
container.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".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();
});