Merge: team directory browse (t-paliad-029)

This commit is contained in:
m
2026-04-25 13:25:42 +02:00
11 changed files with 704 additions and 2 deletions

View File

@@ -28,6 +28,7 @@ import { renderDashboard } from "./src/dashboard";
import { renderAgenda } from "./src/agenda";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
import { renderTeam } from "./src/team";
const DIST = join(import.meta.dir, "dist");
@@ -67,6 +68,7 @@ async function build() {
join(import.meta.dir, "src/client/agenda.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
join(import.meta.dir, "src/client/team.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -116,6 +118,7 @@ async function build() {
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
await Bun.write(join(DIST, "team.html"), renderTeam());
console.log("Build complete \u2192 dist/");
}

View File

@@ -28,6 +28,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Termine",
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.team": "Team",
"nav.group.uebersicht": "\u00dcbersicht",
"nav.group.arbeit": "Arbeit",
"nav.group.werkzeuge": "Werkzeuge",
@@ -1070,6 +1071,19 @@ const translations: Record<Lang, Record<string, string>> = {
"agenda.urgency.tomorrow": "Morgen",
"agenda.urgency.this_week": "Diese Woche",
"agenda.urgency.later": "Später",
// Team directory (t-paliad-029)
"team.title": "Team — Paliad",
"team.heading": "Team",
"team.subtitle": "Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.",
"team.search.placeholder": "Nach Name, Rolle, Büro suchen…",
"team.group.office": "Nach Standort",
"team.group.department": "Nach Dezernat",
"team.group.other": "Sonstige",
"team.filter.all": "Alle",
"team.empty": "Keine Treffer.",
"team.dept.lead": "Lead",
"team.dept.unassigned": "Ohne Dezernat",
},
en: {
@@ -1090,6 +1104,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.termine": "Appointments",
"nav.dashboard": "Dashboard",
"nav.agenda": "Agenda",
"nav.team": "Team",
"nav.group.uebersicht": "Overview",
"nav.group.arbeit": "Work",
"nav.group.werkzeuge": "Tools",
@@ -2132,6 +2147,19 @@ const translations: Record<Lang, Record<string, string>> = {
"agenda.urgency.tomorrow": "Tomorrow",
"agenda.urgency.this_week": "This week",
"agenda.urgency.later": "Later",
// Team directory (t-paliad-029)
"team.title": "Team — Paliad",
"team.heading": "Team",
"team.subtitle": "All Paliad colleagues, grouped by office or department.",
"team.search.placeholder": "Search by name, role, office…",
"team.group.office": "By office",
"team.group.department": "By department",
"team.group.other": "Other",
"team.filter.all": "All",
"team.empty": "No matches.",
"team.dept.lead": "Lead",
"team.dept.unassigned": "No department",
},
};

287
frontend/src/client/team.ts Normal file
View File

@@ -0,0 +1,287 @@
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();
});

View File

@@ -21,6 +21,7 @@ const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_SEARCH = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
interface SidebarProps {
currentPath: string;
@@ -93,7 +94,8 @@ export function Sidebar({ currentPath }: SidebarProps): string {
{group("nav.group.uebersicht", "\u00DCbersicht",
navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) +
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath),
navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) +
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
)}
{group("nav.group.arbeit", "Arbeit",

View File

@@ -6089,3 +6089,218 @@ input[type="range"]::-moz-range-thumb {
.agenda-item-later .agenda-item-urgency { background: #ecfccb; color: #365314; }
.agenda-item-later .agenda-item-icon { background: #ecfccb; color: #365314; }
.agenda-item-later .agenda-item-link { border-left: 3px solid var(--frist-green, #22c55e); }
/* ---------------------------------------------------------------------------
* Team directory (/team) — t-paliad-029
* Browsable list of all Paliad colleagues, grouped by office or department.
* --------------------------------------------------------------------------- */
.team-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.team-controls .glossar-search-wrap {
flex: 1 1 280px;
min-width: 240px;
}
.team-toggle {
display: inline-flex;
gap: 0.5rem;
}
.team-filter-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.team-group {
margin-bottom: 2rem;
}
.team-group-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border, #e5e5ed);
}
.team-group-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--color-text, #1a1a2e);
}
.team-group-sub {
font-size: 0.85rem;
color: var(--color-text-muted, #64647a);
margin-top: 0.15rem;
}
.team-dept-lead {
margin-top: 0.35rem;
font-size: 0.85rem;
color: var(--color-text-muted, #64647a);
}
.team-dept-lead strong {
color: var(--color-text, #1a1a2e);
}
.team-dept-lead a {
color: var(--color-accent, #65a30d);
text-decoration: none;
}
.team-dept-lead a:hover {
text-decoration: underline;
}
.team-group-count {
flex-shrink: 0;
background: var(--color-bg-muted, #f4f4f7);
color: var(--color-text-muted, #64647a);
border-radius: 999px;
padding: 0.15rem 0.7rem;
font-size: 0.85rem;
font-weight: 600;
align-self: center;
}
.team-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 0.85rem;
}
.team-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
padding: 0.9rem 1rem;
background: #fff;
border: 1px solid var(--color-border, #e5e5ed);
border-radius: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.team-card:hover {
border-color: var(--color-accent, #65a30d);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.team-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-accent-light, #84cc16);
color: #1a2e05;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.02em;
}
.team-card-body {
min-width: 0;
flex: 1;
}
.team-card-name {
font-weight: 600;
color: var(--color-text, #1a1a2e);
line-height: 1.2;
overflow-wrap: anywhere;
}
.team-card-role {
font-size: 0.85rem;
color: var(--color-text-muted, #64647a);
margin-top: 0.15rem;
overflow-wrap: anywhere;
}
.team-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
font-size: 0.78rem;
align-items: center;
}
.team-office-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
background: #ecfccb;
color: #365314;
border-radius: 999px;
font-weight: 500;
}
.team-office-badge svg {
flex-shrink: 0;
}
.team-office-extra {
color: var(--color-text-muted, #64647a);
}
.team-dept-tag {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--color-bg-muted, #f4f4f7);
color: var(--color-text-muted, #64647a);
border-radius: 999px;
}
.team-card-email {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-top: 0.6rem;
font-size: 0.82rem;
color: var(--color-accent, #65a30d);
text-decoration: none;
overflow-wrap: anywhere;
word-break: break-all;
}
.team-card-email:hover {
text-decoration: underline;
}
.team-card-email svg {
flex-shrink: 0;
}
@media (max-width: 600px) {
.team-grid {
grid-template-columns: 1fr;
}
.team-controls {
flex-direction: column;
align-items: stretch;
}
.team-toggle {
justify-content: stretch;
}
.team-toggle .filter-pill {
flex: 1 1 0;
text-align: center;
}
}

74
frontend/src/team.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderTeam(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="team.title">Team &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/team" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="team.heading">Team</h1>
<p className="tool-subtitle" data-i18n="team.subtitle">
Alle Paliad-Kolleg:innen, gruppiert nach Standort oder Dezernat.
</p>
</div>
</div>
<div className="team-controls">
<div className="glossar-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="team-search"
className="glossar-search"
placeholder="Suchen / Search..."
data-i18n-placeholder="team.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="team-count" />
</div>
<div className="team-toggle" role="tablist" aria-label="Gruppierung">
<button className="filter-pill active" data-group="office" type="button" data-i18n="team.group.office">
Nach Standort
</button>
<button className="filter-pill" data-group="department" type="button" data-i18n="team.group.department">
Nach Dezernat
</button>
</div>
</div>
<div className="team-filter-row" id="team-office-filters">
<button className="filter-pill active" data-office="all" type="button" data-i18n="team.filter.all">Alle</button>
</div>
<div className="team-list" id="team-list" />
<div className="glossar-empty" id="team-empty" style="display:none">
<p data-i18n="team.empty">Keine Treffer.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/team.js"></script>
</body>
</html>
);
}

View File

@@ -12,6 +12,10 @@ import (
)
// GET /api/departments — list every Dezernat (readable by all authenticated users).
//
// `?include=members` returns each department enriched with its lead's display
// name + email and the full members list. Used by the /team directory page so
// the frontend can render the "group by department" view with one fetch.
func handleListDepartments(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
@@ -19,6 +23,15 @@ func handleListDepartments(w http.ResponseWriter, r *http.Request) {
if _, ok := requireUser(w, r); !ok {
return
}
if r.URL.Query().Get("include") == "members" {
rows, err := dbSvc.department.ListWithMembers(r.Context())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
return
}
rows, err := dbSvc.department.List(r.Context())
if err != nil {
writeServiceError(w, err)

View File

@@ -240,6 +240,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /appointments/calendar", gateOnboarded(handleAppointmentsCalendarPage))
protected.HandleFunc("GET /appointments/{id}", gateOnboarded(handleAppointmentsDetailPage))
// Team directory — browsable list of all onboarded users (t-paliad-029).
protected.HandleFunc("GET /team", gateOnboarded(handleTeamPage))
// Settings
protected.HandleFunc("GET /settings", gateOnboarded(handleSettingsPage))
protected.HandleFunc("GET /settings/caldav", handleSettingsCalDAVRedirect)

View File

@@ -0,0 +1,10 @@
package handlers
import "net/http"
// GET /team — directory of all Paliad users grouped by office or department.
// Server-rendered shell; the client (assets/team.js) hydrates from /api/users
// and /api/departments?include=members.
func handleTeamPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/team.html")
}

View File

@@ -203,6 +203,73 @@ func (s *DepartmentService) ListMembers(ctx context.Context, departmentID uuid.U
return rows, nil
}
// DepartmentWithMembers is a department row enriched with its lead user
// snapshot and full member list. Used by the /team directory page so the
// frontend can render the "by department" grouping with one fetch.
type DepartmentWithMembers struct {
models.Department
LeadDisplayName *string `json:"lead_display_name,omitempty"`
LeadEmail *string `json:"lead_email,omitempty"`
Members []DepartmentMember `json:"members"`
}
// ListWithMembers returns every Department enriched with its lead's display
// name + email and the full members list. Two short queries (one per
// table) are joined in Go to avoid a Cartesian explosion when departments
// have many members.
func (s *DepartmentService) ListWithMembers(ctx context.Context) ([]DepartmentWithMembers, error) {
type deptRow struct {
models.Department
LeadDisplayName *string `db:"lead_display_name"`
LeadEmail *string `db:"lead_email"`
}
var depts []deptRow
err := s.db.SelectContext(ctx, &depts,
`SELECT d.id, d.name, d.lead_user_id, d.office, d.created_at, d.updated_at,
lu.display_name AS lead_display_name,
lu.email AS lead_email
FROM paliad.departments d
LEFT JOIN paliad.users lu ON lu.id = d.lead_user_id
ORDER BY d.office, d.name`)
if err != nil {
return nil, fmt.Errorf("list departments: %w", err)
}
type memberRow struct {
DepartmentMember
DepartmentID uuid.UUID `db:"department_id"`
}
var members []memberRow
err = s.db.SelectContext(ctx, &members,
`SELECT dm.department_id, dm.user_id, dm.created_at,
u.email, u.display_name, u.office, u.role
FROM paliad.department_members dm
LEFT JOIN paliad.users u ON u.id = dm.user_id
ORDER BY u.display_name`)
if err != nil {
return nil, fmt.Errorf("list department members: %w", err)
}
byDept := map[uuid.UUID][]DepartmentMember{}
for _, m := range members {
byDept[m.DepartmentID] = append(byDept[m.DepartmentID], m.DepartmentMember)
}
out := make([]DepartmentWithMembers, len(depts))
for i, d := range depts {
out[i] = DepartmentWithMembers{
Department: d.Department,
LeadDisplayName: d.LeadDisplayName,
LeadEmail: d.LeadEmail,
Members: byDept[d.ID],
}
if out[i].Members == nil {
out[i].Members = []DepartmentMember{}
}
}
return out, nil
}
// GetMembership returns the user's Dezernat memberships (zero or more).
// Used by the settings page to render "Your Dezernat: <name>".
func (s *DepartmentService) GetMembership(ctx context.Context, userID uuid.UUID) ([]models.Department, error) {

View File

@@ -41,7 +41,7 @@ func NewUserService(db *sqlx.DB) *UserService {
return &UserService{db: db}
}
const userColumns = `id, email, display_name, office, practice_group, role, dezernat,
const userColumns = `id, email, display_name, office, additional_offices, practice_group, role, dezernat,
lang, email_preferences, created_at, updated_at`
// GetByID returns the user row, or (nil, nil) if the user hasn't completed