Files
paliad/frontend/src/client/team.ts
m 28d747e656 feat(team): browsable team directory grouped by office or department (t-paliad-029)
Adds /team page that lists every onboarded Paliad user, grouped by office
(default) or by department, with a free-text search and per-office filter
pills. Each card shows display name, role, primary office (with any
additional offices), department tag, and a mailto: link.

Backend:
- /api/users now also returns additional_offices (column was already on the
  model + DB; just missing from the SELECT list).
- /api/departments?include=members returns each department enriched with
  its lead user snapshot and the full member list — single fetch for the
  "by department" grouping.
- New page handler /team behind the onboarding gate.

Frontend:
- frontend/src/team.tsx + frontend/src/client/team.ts (new) for the page
  shell and client-side rendering / filtering.
- New "Team" entry in the Übersicht sidebar group with a users icon.
- DE/EN i18n keys (nav.team, team.*).
- Team-specific CSS for cards, group headers, avatars, and badges.
2026-04-25 13:22:17 +02:00

288 lines
9.9 KiB
TypeScript

import { initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
additional_offices?: string[];
role: string;
dezernat?: string;
}
interface DepartmentMember {
user_id: string;
email: string;
display_name: string;
office: string;
role: string;
}
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"];
let users: User[] = [];
let departments: Department[] = [];
let groupBy: "office" | "department" = "office";
let activeOffice = "all";
let searchQuery = "";
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
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 t("office." + key) || key;
}
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] = await Promise.all([
fetch("/api/users"),
fetch("/api/departments?include=members"),
]);
if (usersResp.ok) users = (await usersResp.json()) as User[];
if (deptsResp.ok) departments = (await deptsResp.json()) as Department[];
buildOfficeFilters();
render();
}
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();
});
});
}
function userMatchesSearch(u: User): boolean {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
const hay = [
u.display_name,
u.email,
u.role,
u.office,
officeLabel(u.office),
u.dezernat ?? "",
...(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 memberAsUser(m: DepartmentMember): User | undefined {
return users.find((u) => u.id === m.user_id);
}
function renderUserCard(u: User): string {
const dept = u.dezernat ?? "";
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
return `
<article class="team-card">
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
<div class="team-card-body">
<div class="team-card-name">${esc(u.display_name)}</div>
${u.role ? `<div class="team-card-role">${esc(u.role)}</div>` : ""}
<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>` : ""}
${dept ? `<span class="team-dept-tag">${esc(dept)}</span>` : ""}
</div>
<a class="team-card-email" href="mailto:${esc(u.email)}">${ICON_MAIL}<span>${esc(u.email)}</span></a>
</div>
</article>`;
}
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 department (fall back: free-text dezernat field, or none).
const inAnyDept = new Set<string>();
for (const d of departments) for (const m of d.members) inAnyDept.add(m.user_id);
const looseGroups = new Map<string, User[]>();
for (const u of filtered) {
if (inAnyDept.has(u.id)) continue;
const key = (u.dezernat ?? "").trim() || "__none__";
if (!looseGroups.has(key)) looseGroups.set(key, []);
looseGroups.get(key)!.push(u);
}
const looseKeys = Array.from(looseGroups.keys()).sort((a, b) => {
if (a === "__none__") return 1;
if (b === "__none__") return -1;
return a.localeCompare(b);
});
for (const key of looseKeys) {
const list = looseGroups.get(key)!;
const heading = key === "__none__"
? (t("team.dept.unassigned") || "Ohne Dezernat")
: key;
sections.push(`
<section class="team-group team-group-loose">
<header class="team-group-header">
<h2>${esc(heading)}</h2>
<span class="team-group-count">${list.length}</span>
</header>
<div class="team-grid">${list.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) && userMatchesSearch(u));
count.textContent = `${filtered.length} / ${users.length}`;
if (filtered.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = groupBy === "office"
? renderGroupByOffice(filtered)
: renderGroupByDepartment(filtered);
}
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();
onLangChange(() => {
buildOfficeFilters();
render();
});
loadAll();
});