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.
288 lines
9.9 KiB
TypeScript
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();
|
|
});
|