Files
tessera-ctl/apps/web/src/components/layout/sidebar.tsx
T
schalli e780b2cc69 fix(web): Absicht/Ballast der letzten sechs useExhaustiveDependencies-Befunde entschieden
Befunde 16-21 (quick-260921-gof), Regel-Gesamtstand jetzt 0/446 (vorher
21/467), pnpm lint 5/5, pnpm type-check 4/4:

- InvoiceHistoryTable.tsx (16) und sidebar.tsx (17): refreshKey /
  sidebarRefreshKey bleiben als begruendete Auffrisch-Ausloeser stehen
  (biome-ignore mit deutschem Grund) - ohne sie zeigt die DKV-Historie
  nach "Jetzt pruefen" bzw. die Seitenleiste nach einer Modul-
  Aktivierung den alten Stand.
- ActivateModuleDialog.tsx (18): moduleId aus der Abhaengigkeitsliste
  entfernt - reiner Ballast, der Effekt holt ohnehin nur die vom Modul
  unabhaengige Gruppenliste und die Elternseite haengt den Dialog je
  Modul frisch ein.
- GroupMembersModal.tsx (19/20): fetchMembers/fetchAllUsers in die
  Liste aufgenommen (echter Defekt) - ohne sie zeigt der Dialog bei
  einem Gruppenwechsel ohne Neuaufbau die Mitglieder der vorigen
  Gruppe. Neue Testdatei nach dem grants-matrix.test.tsx-Muster belegt
  genau diesen Fall.
- grants/page.tsx (21): die Suchabgleich-Hilfsfunktion `matches` in den
  Merkungs-Rumpf verschoben statt im Bauteil-Rumpf zu bleiben - reiner
  Ballast, die bestehenden Suchfaelle bleiben unveraendert gruen.
- sidebar.test.tsx um eine Zaehlprobe erweitert: Bump loest genau einen
  weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen.
- Alle drei begruendeten biome-ignore-Zeilen sind jetzt gesetzt (erste
  Verwendung dieses Mechanismus im Projekt), alle wirkungslosen
  eslint-disable-Zeilen fuer diese Regel sind aus apps/web/src
  verschwunden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 12:34:55 +02:00

270 lines
12 KiB
TypeScript

'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { AppVersionBadge } from '@/components/layout/app-version-badge';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface SidebarModule {
id: string;
slug: string;
name: string;
category: string;
}
export function Sidebar() {
const t = useTranslations('sidebar');
const tCommon = useTranslations('common');
const { isCollapsed, isMobileOpen, toggle, setMobileOpen } = useSidebarStore();
const [activeModules, setActiveModules] = useState<SidebarModule[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [openCategories, setOpenCategories] = useState<Set<string>>(new Set());
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
const isActive = (href: string) => {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
};
const linkClass = (href: string) =>
`flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors ${
isActive(href)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
}`;
const fetchActiveModules = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
});
if (res.ok) {
setActiveModules(await res.json());
}
} catch {
// silently fail
}
}, []);
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
// aktiviertes Modul in der Seitenleiste erst nach einem Neuladen der
// Seite, die Navigation liefe also dem Berechtigungsstand hinterher.
// biome-ignore lint/correctness/useExhaustiveDependencies: sidebarRefreshKey ist der Auffrisch-Ausloeser aus dem Marketplace-Speicher - ohne ihn liefe die Navigation dem Berechtigungsstand hinterher.
useEffect(() => {
fetchActiveModules();
}, [fetchActiveModules, sidebarRefreshKey]);
const toggleCategory = (cat: string) => {
setOpenCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
};
const filteredCategories = useMemo(() => {
const categories = new Map<string, SidebarModule[]>();
for (const mod of activeModules) {
const cat = mod.category || 'other';
if (!categories.has(cat)) categories.set(cat, []);
categories.get(cat)!.push(mod);
}
if (!searchQuery) return Array.from(categories.entries());
const q = searchQuery.toLowerCase();
const result: [string, SidebarModule[]][] = [];
for (const [cat, mods] of categories.entries()) {
if (cat.toLowerCase().includes(q)) {
result.push([cat, mods]);
} else {
const filtered = mods.filter((m) => m.name.toLowerCase().includes(q));
if (filtered.length > 0) result.push([cat, filtered]);
}
}
return result;
}, [activeModules, searchQuery]);
const sidebarContent = (
<div className="flex h-full flex-col bg-sidebar">
<nav className="flex-1 overflow-y-auto p-3">
<ul className="flex flex-col gap-1">
<li>
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
{!isCollapsed && <span className="truncate">{t('dashboard')}</span>}
</Link>
</li>
<li>
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')}>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{!isCollapsed && <span className="truncate">{t('marketplace')}</span>}
</Link>
</li>
</ul>
{!isCollapsed && (
<>
<div className="mt-4">
<SidebarSearch value={searchQuery} onChange={setSearchQuery} />
</div>
<div className="mt-2">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('categories.label')}
</div>
<div className="mt-1">
{activeModules.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('categories.empty')}
</div>
) : filteredCategories.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground">
{t('noResults')}
</div>
) : (
<ul className="flex flex-col gap-1">
{filteredCategories.map(([category, mods]) => (
<li key={category}>
<button
type="button"
onClick={() => toggleCategory(category)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs font-medium transition-colors ${
isActive(`/modules/${category}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span className="capitalize">{category.replace(/-/g, ' ')}</span>
<span className="ml-auto flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/60">{mods.length}</span>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`transition-transform duration-200 ${openCategories.has(category) ? 'rotate-180' : ''}`}>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</button>
{openCategories.has(category) && (
<ul className="ml-5 flex flex-col gap-0.5">
{mods.map((mod) => (
<li key={mod.id}>
<Link
href={`/modules/${category}/${mod.slug}`}
className={`block rounded-md px-2 py-1 text-xs transition-colors ${
isActive(`/modules/${category}/${mod.slug}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{mod.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</nav>
<div className="hidden md:block border-t border-sidebar-border p-2">
<button
type="button"
onClick={toggle}
className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-sm text-sidebar-foreground hover:bg-muted transition-colors"
aria-label={isCollapsed ? t('expand') : t('collapse')}
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
<polyline points="15 18 9 12 15 6" />
</svg>
{!isCollapsed && <span className="truncate">{t('collapse')}</span>}
</button>
</div>
{/* Versionszeile (quick-260914-ku1): bewusst ohne `hidden md:block`, damit
auch die mobile Schublade sie zeigt; eingeklappt nichts (Muster oben). */}
{!isCollapsed && (
<div className="border-t border-sidebar-border px-4 py-2">
<AppVersionBadge />
</div>
)}
</div>
);
return (
<>
<aside
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-all duration-200 md:block"
style={{
width: isCollapsed
? 'var(--sidebar-width-collapsed)'
: 'var(--sidebar-width)',
}}
>
{sidebarContent}
</aside>
{isMobileOpen && (
<div className="fixed inset-0 z-40 md:hidden">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setMobileOpen(false)}
aria-hidden="true"
/>
<aside className="fixed left-0 top-0 z-50 h-full w-[var(--sidebar-width)] bg-sidebar shadow-lg">
<div className="flex h-[var(--header-height)] items-center justify-between border-b border-sidebar-border px-4">
<TesseraLogo
variant="horizontal"
size={28}
// Gelb traegt nur auf dunklem Grund: gemessen 13,1:1 im dunklen
// Erscheinungsbild gegen 1,26:1 im hellen (WCAG verlangt 3:1 fuer
// grossen fetten Text). Im hellen Modus erbt der Schriftzug daher
// die Vordergrundfarbe, im dunklen steht die Markenfarbe.
wordmarkClassName="dark:text-primary"
/>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label={tCommon('close')}
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{sidebarContent}
</aside>
</div>
)}
</>
);
}