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.
867 lines
32 KiB
TypeScript
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, """);
|
|
}
|
|
|
|
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();
|
|
});
|