From b06a2d1066c6d9303ed78347f75a1a8073aec3b5 Mon Sep 17 00:00:00 2001 From: Schalli Date: Sat, 26 Sep 2026 13:09:05 +0200 Subject: [PATCH] feat(web): Dashboard mit Begruessung, Befehlsleiste und ruhigeren Kacheln Begruessung nach Tageszeit mit Datum, Befehlsleiste oben rechts statt schwebendem Knopf, einheitliche Kachel-Kopfzeilen mit Symbol, Kalender mit Pfeilknoepfen, runden Tagen und Terminpunkten, Favoriten als 36-px-Zeilen bzw. App-Starter-Kacheln, Notiz-Kaestchen im Akzent, leerer Zustand mit drei Vorschlaegen. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/app/(portal)/page.test.tsx | 29 +++--- apps/web/src/app/(portal)/page.tsx | 80 ++++++++++------- apps/web/src/app/globals.css | 45 ++++++++++ .../dashboard/dashboard-background.tsx | 4 +- .../dashboard/dashboard-greeting.tsx | 57 ++++++++++++ .../dashboard/dashboard-grid.test.tsx | 34 ++++++- .../components/dashboard/dashboard-grid.tsx | 63 ++++++++++--- .../components/dashboard/edit-mode-toggle.tsx | 15 ++-- .../widgets/calendar-widget.test.tsx | 16 ++-- .../dashboard/widgets/calendar-widget.tsx | 68 +++++++------- .../dashboard/widgets/favorites-widget.tsx | 45 +++++++--- .../dashboard/widgets/note-widget.tsx | 2 + .../dashboard/widgets/proxmox-widget.tsx | 3 + .../dashboard/widgets/widget-icon.tsx | 88 +++++++++++++++++++ .../dashboard/widgets/widget-wrapper.tsx | 22 ++++- .../dashboard/widgets/xframe-widget.tsx | 2 + apps/web/src/messages/de.json | 23 ++++- apps/web/src/messages/en.json | 23 ++++- 18 files changed, 484 insertions(+), 135 deletions(-) create mode 100644 apps/web/src/components/dashboard/dashboard-greeting.tsx create mode 100644 apps/web/src/components/dashboard/widgets/widget-icon.tsx diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index 8877407..26c394f 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -11,12 +11,16 @@ const messages: Record> = { editMode: 'Dashboard bearbeiten', saveChanges: 'Änderungen speichern', addWidget: 'Widget hinzufügen', + editShort: 'Bearbeiten', + done: 'Fertig', + toolbarLabel: 'Dashboard-Aktionen', layoutLoadError: 'Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.', }, }; vi.mock('next-intl', () => ({ useTranslations: (ns: string) => (key: string) => messages[ns]?.[key] ?? key, + useLocale: () => 'de', })); const mockStore = { @@ -106,45 +110,46 @@ afterEach(() => { }); describe('DashboardPage (quick-260916-dyv)', () => { - it('Test 1: Ansichtsmodus — Stift in fester Leiste unten rechts, kein "Widget hinzufuegen", kein mt-8/top-2, Grid direkt im Container p-2', async () => { + it('Test 1: Ansichtsmodus — Befehlsleiste rechts oben mit „Bearbeiten“, kein "Widget hinzufuegen", Begruessung, Grid direkt im Container p-2 (Design „Mosaik“)', async () => { const { default: DashboardPage } = await import('./page'); render(); - const toggle = screen.getByRole('button', { name: 'Dashboard bearbeiten' }); - const bar = toggle.parentElement as HTMLElement; - expect(bar.classList.contains('fixed')).toBe(true); - expect(bar.classList.contains('bottom-6')).toBe(true); - expect(bar.classList.contains('right-6')).toBe(true); - expect(bar.classList.contains('z-20')).toBe(true); + const toolbar = screen.getByRole('toolbar', { name: 'Dashboard-Aktionen' }); + const toggle = screen.getByRole('button', { name: 'Bearbeiten' }); + expect(toggle.parentElement).toBe(toolbar); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + // kein schwebender Knopf mehr unten rechts + expect(toolbar.classList.contains('fixed')).toBe(false); + expect(document.querySelector('.bottom-6')).toBeNull(); expect(screen.queryByRole('button', { name: 'Widget hinzufügen' })).toBeNull(); expect(document.querySelector('.mt-8')).toBeNull(); expect(document.querySelector('.top-2')).toBeNull(); + await waitFor(() => expect(screen.getByTestId('dashboard-greeting')).toBeInTheDocument()); const grid = screen.getByTestId('dashboard-grid'); expect((grid.parentElement as HTMLElement).classList.contains('p-2')).toBe(true); expect(grid.getAttribute('data-edit')).toBe('false'); }); - it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben dem Haekchen in derselben Leiste, Grid im Bearbeitungsmodus', async () => { + it('Test 2: Bearbeitungsmodus — "Widget hinzufuegen" links neben „Fertig“ in derselben Leiste, Grid im Bearbeitungsmodus', async () => { mockStore.isEditMode = true; const { default: DashboardPage } = await import('./page'); render(); - const toggle = screen.getByRole('button', { name: 'Änderungen speichern' }); + const toggle = screen.getByRole('button', { name: 'Fertig' }); const add = screen.getByRole('button', { name: 'Widget hinzufügen' }); expect(add.parentElement).toBe(toggle.parentElement); - // "Widget hinzufuegen" steht im DOM VOR dem Umschalter (links daneben). expect(add.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByTestId('dashboard-grid').getAttribute('data-edit')).toBe('true'); }); - it('Test 3: Umschalten — Klick auf "Dashboard bearbeiten" ruft setEditMode(true) genau einmal', async () => { + it('Test 3: Umschalten — Klick auf "Bearbeiten" ruft setEditMode(true) genau einmal', async () => { const { default: DashboardPage } = await import('./page'); render(); - fireEvent.click(screen.getByRole('button', { name: 'Dashboard bearbeiten' })); + fireEvent.click(screen.getByRole('button', { name: 'Bearbeiten' })); expect(mockStore.setEditMode).toHaveBeenCalledTimes(1); expect(mockStore.setEditMode).toHaveBeenCalledWith(true); }); diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index a4d6279..7aed16b 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { DashboardTabs } from '@/components/dashboard/dashboard-tabs'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; +import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting'; import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background'; import { useAuthStore } from '@/lib/stores/auth-store'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; @@ -138,34 +139,27 @@ export default function DashboardPage() { onReorder={reorderDashboards} /> - {isSwitchingDashboard ? ( -
-

{t('tabs.switching')}

+ {/* Kopfzeile des Dashboards (Design „Mosaik“, Runde 2): Begruessung + links, Befehlsleiste rechts (Fluent „command bar“) statt des + frueheren schwebenden Knopfs unten rechts. Mit Hintergrund liegen + beide auf der durchscheinenden Kachelflaeche (.dashboard-chrome). */} +
+
+
- ) : ( - /* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */ - - )} - - {/* Feste Aktionsleiste unten rechts. - quick-260916-dyv: Umschalter unten rechts statt oben rechts, damit das - Grid direkt unter der Kopfzeile beginnt (12 + 8 + 8 = 28 px statt 60 px). - Im Bearbeitungsmodus steht "Widget hinzufuegen" links neben dem Haekchen. */} -
- {isEditMode && } - {isEditMode && ( - - )} - setEditMode(!isEditMode)} - /> + {t('addWidget')} + + )} + setEditMode(!isEditMode)} + /> +
+ {isSwitchingDashboard ? ( +
+

{t('tabs.switching')}

+
+ ) : ( + /* Dashboard grid — direkt im Container, ohne Abstands-Wrapper. */ + setCatalogOpen(true)} + onQuickAdd={(type) => addWidget(type)} + /> + )} + {/* Widget catalog modal */} setOpen((v) => !v)} aria-expanded={open} aria-haspopup="dialog" - className="btn btn-secondary shadow-lg" + className="btn btn-subtle" >

{t('title')}

{t('hint')}

diff --git a/apps/web/src/components/dashboard/dashboard-greeting.tsx b/apps/web/src/components/dashboard/dashboard-greeting.tsx new file mode 100644 index 0000000..319f0a1 --- /dev/null +++ b/apps/web/src/components/dashboard/dashboard-greeting.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useEffect, useState } from 'react'; +import { useAuthStore } from '@/lib/stores/auth-store'; + +/** Tageszeit fuer die Begruessung: 5–10 Uhr Morgen, 11–17 Uhr Tag, sonst Abend. */ +export function greetingPeriod(date: Date): 'morning' | 'day' | 'evening' { + const hour = date.getHours(); + if (hour >= 5 && hour < 11) return 'morning'; + if (hour >= 11 && hour < 18) return 'day'; + return 'evening'; +} + +/** Vorname aus dem Anzeigenamen (erstes Wort), sonst der Benutzername. */ +export function greetingName(displayName: string | null | undefined, username: string | undefined): string { + const first = displayName?.trim().split(/\s+/)[0]; + return first || username || ''; +} + +/** + * Ruhige Begruessung ueber dem Raster (Design „Mosaik“, Runde 2): „Guten + * Morgen, Max“ plus Wochentag und Datum. Keine Kennzahlen. Die Uhrzeit wird + * erst im Browser gelesen (kein Unterschied zwischen Server und Browser) und + * jede Minute aufgefrischt. + */ +export function DashboardGreeting() { + const t = useTranslations('dashboard.greeting'); + const locale = useLocale(); + const user = useAuthStore((s) => s.user); + const [now, setNow] = useState(null); + + useEffect(() => { + setNow(new Date()); + const id = window.setInterval(() => setNow(new Date()), 60_000); + return () => window.clearInterval(id); + }, []); + + if (!now) return