Merge: team directory browse (t-paliad-029)
This commit is contained in:
@@ -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/");
|
||||
}
|
||||
|
||||
@@ -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
287
frontend/src/client/team.ts
Normal 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();
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
74
frontend/src/team.tsx
Normal 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 — 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>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
10
internal/handlers/team_pages.go
Normal file
10
internal/handlers/team_pages.go
Normal 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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user