diff --git a/CHANGELOG.md b/CHANGELOG.md index 26638ba..93b73c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T ### Neu +- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe. +- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen. - Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe. - Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert. - Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein. diff --git a/apps/api/src/dashboard/widget-module-map.spec.ts b/apps/api/src/dashboard/widget-module-map.spec.ts index 6b908aa..d93d307 100644 --- a/apps/api/src/dashboard/widget-module-map.spec.ts +++ b/apps/api/src/dashboard/widget-module-map.spec.ts @@ -21,14 +21,26 @@ describe('widget-module-map (quick-260922-m1h)', () => { } }); - // quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle - // uebrigen bleiben Plattform-Kacheln ohne Modulbezug. - it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => { - for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) { + // quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel, + // quick-261002-k67 ergaenzt Nextcloud-Status; alle uebrigen bleiben + // Plattform-Kacheln ohne Modulbezug. + it('nur proxmox und nextcloud-status tragen einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => { + expect(Object.keys(WIDGET_MODULE_MAP).sort()).toEqual(['nextcloud-status', 'proxmox']); + for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox' && t !== 'nextcloud-status')) { expect(getModuleSlugForWidgetType(type)).toBeUndefined(); } }); + it("die Nextcloud-Status-Kachel gehoert zum Modul 'nextcloud-status' (T-k67-04)", () => { + expect(getModuleSlugForWidgetType('nextcloud-status')).toBe('nextcloud-status'); + }); + + it('WIDGET_TYPES endet mit nextcloud-status (zwoelf Typen, Reihenfolge unveraendert)', () => { + expect(WIDGET_TYPES).toHaveLength(12); + expect(WIDGET_TYPES.at(-1)).toBe('nextcloud-status'); + expect(WIDGET_TYPES.at(-2)).toBe('reminder'); + }); + it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => { expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox'); }); diff --git a/apps/api/src/dashboard/widget-module-map.ts b/apps/api/src/dashboard/widget-module-map.ts index c5d031d..a47e259 100644 --- a/apps/api/src/dashboard/widget-module-map.ts +++ b/apps/api/src/dashboard/widget-module-map.ts @@ -23,16 +23,17 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared'; * für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5). * - * Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` → - * `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/ - * calculator/favorites/stopwatch/picture-frame/xframe) sind - * Plattform-Widgets ohne Modulbezug. + * Seit quick-261002-k67 stehen dort zwei Einträge: `proxmox` → `proxmox` + * (quick-260924-i8v) und `nextcloud-status` → `nextcloud-status`. Die + * übrigen Widget-Typen (clock/search/calendar/note/calculator/favorites/ + * stopwatch/picture-frame/xframe/reminder) sind Plattform-Widgets ohne + * Modulbezug. */ export const WIDGET_MODULE_MAP: Readonly> = WIDGET_MODULE_SLUGS; /** * Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn - * der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle, + * der Typ kein Modul-Widget ist (alle Typen außer `proxmox` und `nextcloud-status`). Einziger Lesezugriff auf die Zuordnungstabelle, * damit Tests sie gezielt mocken können. */ export function getModuleSlugForWidgetType(widgetType: string): string | undefined { diff --git a/apps/web/src/app/(portal)/page.test.tsx b/apps/web/src/app/(portal)/page.test.tsx index d3d72f6..3b9b2bc 100644 --- a/apps/web/src/app/(portal)/page.test.tsx +++ b/apps/web/src/app/(portal)/page.test.tsx @@ -84,6 +84,9 @@ vi.mock('@/components/dashboard/widgets/favorites-widget', () => ({ FavoritesWid vi.mock('@/components/dashboard/widgets/picture-frame-widget', () => ({ PictureFrameWidget: () => null })); vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null })); vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null })); +vi.mock('@/components/dashboard/widgets/nextcloud-status-widget', () => ({ + NextcloudStatusWidget: () => null, +})); beforeEach(() => { catalogProps.accessibleModuleSlugs = undefined; diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 4495df3..c5eae22 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -18,6 +18,7 @@ import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widg import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget'; +import { NextcloudStatusWidget } from '@/components/dashboard/widgets/nextcloud-status-widget'; import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; @@ -45,6 +46,7 @@ registerWidget('picture-frame', PictureFrameWidget); registerWidget('xframe', XframeWidget); registerWidget('proxmox', ProxmoxWidget); registerWidget('reminder', ReminderWidget); +registerWidget('nextcloud-status', NextcloudStatusWidget); /** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */ interface ActiveModule { diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx index 9f89789..7c90d84 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.test.tsx @@ -29,6 +29,8 @@ vi.mock('next-intl', () => ({ 'xframe.description': 'Webseite einbetten', 'proxmox.name': 'Proxmox', 'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick', + 'nextcloudStatus.name': 'Nextcloud-Status', + 'nextcloudStatus.description': 'Ampelübersicht Ihrer Nextcloud-Clouds', }, common: { close: 'Schließen', @@ -44,7 +46,8 @@ import { WidgetCatalogModal } from './widget-catalog-modal'; // Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei // leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff -// auf das Modul 'proxmox'. +// auf das Modul 'proxmox', die Nextcloud-Status-Kachel (quick-261002-k67) nur +// mit Zugriff auf das Modul 'nextcloud-status'. const baseProps = { isOpen: true, onClose: vi.fn(), @@ -151,18 +154,33 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)', ).map((c) => c.getAttribute('data-widget-type')); } - it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => { - render(); + it("zeigt mit Zugriff auf beide Module alle zwoelf Kacheln in der Reihenfolge der Registry, Nextcloud-Status zuletzt", () => { + render( + , + ); expect(catalogTypes()).toEqual([...WIDGET_TYPES]); - expect(catalogTypes().at(-1)).toBe('reminder'); + expect(catalogTypes().at(-1)).toBe('nextcloud-status'); expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]); }); - it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => { + it('zeigt ohne Modulzugriff die zehn Plattform-Kacheln ohne die beiden Modul-Kacheln', () => { render(); - expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox')); + expect(catalogTypes()).toEqual( + WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'), + ); + }); + + it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu, Proxmox bleibt aus", () => { + render(); + + expect(catalogTypes()).toContain('nextcloud-status'); + expect(catalogTypes()).not.toContain('proxmox'); + expect(screen.getByRole('button', { name: /Nextcloud-Status/ })).toBeInTheDocument(); }); it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => { diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx index f75d990..2f3bcac 100644 --- a/apps/web/src/components/dashboard/widget-registry.test.tsx +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -35,6 +35,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [ 'proxmox', // Erinnerungen (quick-260929-if2) 'reminder', + // Nextcloud-Status — zweite Modul-Kachel (quick-261002-k67) + 'nextcloud-status', ]; describe('WIDGET_CONSTRAINTS (DASH-11)', () => { @@ -72,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); }); - it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => { + it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu; quick-261002-k67: Nextcloud-Status dazu, zwoelf Typen)', () => { // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW @@ -90,6 +92,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 }, proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 }, reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, + 'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }, }); let counted = 0; @@ -99,7 +102,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => { counted++; } } - expect(counted).toBe(44); + expect(counted).toBe(48); }); }); @@ -147,7 +150,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => { expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]); }); - it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { + it('die zwoelf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => { expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES); }); @@ -157,10 +160,12 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => { } }); - it("nur proxmox traegt moduleSlug 'proxmox', alle anderen Kacheln keinen (quick-260924-i8v)", () => { + it("nur proxmox und nextcloud-status tragen einen moduleSlug (gleichnamig), alle anderen Kacheln keinen (quick-260924-i8v, quick-261002-k67)", () => { for (const type of WIDGET_TYPES) { if (type === 'proxmox') { expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox'); + } else if (type === 'nextcloud-status') { + expect(WIDGET_REGISTRY[type].moduleSlug).toBe('nextcloud-status'); } else { expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined(); } @@ -229,14 +234,22 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => { note: {}, }; - it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => { + it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlen nur die beiden Modul-Kacheln', () => { expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual( - WIDGET_TYPES.filter((type) => type !== 'proxmox'), + WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'), ); }); - it("mit Zugriff auf 'proxmox' erscheinen alle zehn Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v)", () => { - expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox'])).toEqual([...WIDGET_TYPES]); + it("mit Zugriff auf beide Module erscheinen alle zwoelf Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v, quick-261002-k67)", () => { + expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox', 'nextcloud-status'])).toEqual([ + ...WIDGET_TYPES, + ]); + }); + + it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu (quick-261002-k67)", () => { + expect(visibleWidgetTypes(WIDGET_REGISTRY, ['nextcloud-status'])).toEqual( + WIDGET_TYPES.filter((type) => type !== 'proxmox'), + ); }); it('Kacheln ohne moduleSlug sind immer sichtbar', () => { diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index 863e3bb..eceff8d 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -20,6 +20,7 @@ import type { ComponentType } from 'react'; * xframe: Webseite als Rahmen (quick-260921-qd3). * proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v). * reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2). + * nextcloud-status: Ampeluebersicht der Nextcloud-Clouds, zweite Modul-Kachel (quick-261002-k67). */ export type { WidgetType }; @@ -102,6 +103,12 @@ export const WIDGET_CONSTRAINTS: Record< // 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 = // rund 340x270 px: drei bis vier Erinnerungen auf einmal. reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, + // quick-261002-k67: Vorbild Proxmox. 4 Zeilen = 104 px reichen fuer die + // drei Zaehler (Rahmenkopf plus Zaehlerzeile); darunter blendet sich die + // Liste der roten/gelben Clouds per Container-Query aus. 12x8 (im 48er- + // Raster) = rund 340x216 px: Zaehler und etwa drei Clouds mit Grund. + // 6 Spalten = rund 166 px zeigen nur die Zaehler. + 'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }, }; /** @@ -132,7 +139,7 @@ export interface WidgetDefinition { /** * Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus * `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel - * und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox. + * und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox und Nextcloud-Status. */ moduleSlug?: string; /** Inline SVG icon as React component */ @@ -410,6 +417,26 @@ function ReminderIcon({ className }: { className?: string }) { ); } +function NextcloudStatusIcon({ className }: { className?: string }) { + return ( + + ); +} + /** * Registry of all widget types. Used by the widget catalog modal * and the grid renderer to look up components and constraints. @@ -516,6 +543,17 @@ export const WIDGET_REGISTRY: Record = { moduleSlug: WIDGET_MODULE_SLUGS.reminder, component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt }, + 'nextcloud-status': { + type: 'nextcloud-status', + nameKey: 'nextcloudStatus.name', + descriptionKey: 'nextcloudStatus.description', + icon: NextcloudStatusIcon, + ...WIDGET_CONSTRAINTS['nextcloud-status'], + // Zweite Modul-Kachel: ohne Zugriff auf das Modul 'nextcloud-status' fehlt + // sie im Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard. + moduleSlug: WIDGET_MODULE_SLUGS['nextcloud-status'], + component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt + }, }; /** diff --git a/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx b/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx new file mode 100644 index 0000000..c008f48 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx @@ -0,0 +1,160 @@ +import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NextcloudInstance, NextcloudList, RatingLevel } from '@/lib/nextcloud-status-api'; +import de from '@/messages/de.json'; +import { NextcloudStatusWidget } from './nextcloud-status-widget'; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +const mockListInstances = vi.fn(); +const mockCheckAll = vi.fn(); +const mockCheckOne = vi.fn(); + +vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listInstances: (...a: unknown[]) => mockListInstances(...a), + checkAll: (...a: unknown[]) => mockCheckAll(...a), + checkOne: (...a: unknown[]) => mockCheckOne(...a), + }; +}); + +vi.mock('next/link', () => ({ + default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})); + +function make( + id: string, + customerName: string, + level: RatingLevel, + reason: NextcloudInstance['rating']['reason'] = 'current', + updateTo: string | null = null, +): NextcloudInstance { + return { + id, + customerName, + baseUrl: `https://${id}.example.de`, + logoUrl: null, + hasUploadedLogo: false, + logoVersion: 0, + status: { + checkedAt: null, + reachable: true, + maintenance: false, + needsDbUpgrade: false, + versionString: '35.0.1', + edition: null, + errorKind: null, + errorDetail: null, + }, + rating: { level, reason, updateTo, eolDate: null, cycle: 35 }, + }; +} + +function list(instances: NextcloudInstance[]): NextcloudList { + return { instances, reference: { newestVersion: '35.0.1', fetchedAt: null } }; +} + +const props = { instanceId: 'w1', config: {}, isEditMode: false }; + +describe('NextcloudStatusWidget', () => { + beforeEach(() => { + mockListInstances.mockReset(); + mockCheckAll.mockReset(); + mockCheckOne.mockReset(); + }); + afterEach(() => cleanup()); + + it('zeigt Zähler 2/1/1 und Rot vor Gelb mit Name und Grund', async () => { + mockListInstances.mockResolvedValue( + list([ + make('g1', 'Grün A', 'green'), + make('g2', 'Grün B', 'green'), + make('y1', 'Gelb A', 'yellow', 'update-available', '34.0.4'), + make('r1', 'Rot A', 'red', 'maintenance'), + ]), + ); + render(); + await screen.findByTestId('nextcloud-counters'); + expect(screen.getByTestId('nextcloud-count-green')).toHaveTextContent('2'); + expect(screen.getByTestId('nextcloud-count-yellow')).toHaveTextContent('1'); + expect(screen.getByTestId('nextcloud-count-red')).toHaveTextContent('1'); + expect(screen.queryByTestId('nextcloud-count-unknown')).toBeNull(); + + const rows = screen.getAllByTestId('nextcloud-row'); + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText('Rot A')).toBeInTheDocument(); + expect(within(rows[0]).getByText('Wartungsmodus')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Gelb A')).toBeInTheDocument(); + expect(within(rows[1]).getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument(); + }); + + it('alles grün: ruhige Zeile, keine Liste', async () => { + mockListInstances.mockResolvedValue(list([make('g1', 'Grün A', 'green')])); + render(); + expect(await screen.findByText('Alle Clouds sind aktuell')).toBeInTheDocument(); + expect(screen.queryByTestId('nextcloud-list')).toBeNull(); + }); + + it('ohne Clouds ein Hinweis', async () => { + mockListInstances.mockResolvedValue(list([])); + render(); + expect(await screen.findByText('Noch keine Cloud eingetragen')).toBeInTheDocument(); + }); + + it('Ladefehler zeigt den Fehlersatz', async () => { + mockListInstances.mockRejectedValue(new Error('boom')); + render(); + expect(await screen.findByText('Status konnte nicht geladen werden')).toBeInTheDocument(); + }); + + it('zählt Clouds ohne Bewertung getrennt, aber nur wenn es welche gibt', async () => { + mockListInstances.mockResolvedValue( + list([make('g1', 'Grün A', 'green'), make('u1', 'Grau A', 'unknown', 'no-reference')]), + ); + render(); + await screen.findByTestId('nextcloud-counters'); + expect(screen.getByTestId('nextcloud-count-unknown')).toHaveTextContent('1'); + // Mit einer unbewerteten Cloud gilt „Alle aktuell“ nicht. + expect(screen.queryByText('Alle Clouds sind aktuell')).toBeNull(); + }); + + it('Ansichtsmodus: Zeilen und Zählerbereich führen zur Modulseite', async () => { + mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')])); + render(); + await screen.findByTestId('nextcloud-list'); + const links = screen.getAllByRole('link'); + expect(links.length).toBeGreaterThanOrEqual(2); + for (const link of links) expect(link).toHaveAttribute('href', '/modules/nextcloud-status'); + }); + + it('Bearbeitungsmodus: keine Links', async () => { + mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')])); + render(); + await screen.findByTestId('nextcloud-list'); + expect(screen.queryByRole('link')).toBeNull(); + expect(screen.getByText('Rot A')).toBeInTheDocument(); + }); + + it('ruft nie eine Prüfung auf, nur die Leseabfrage', async () => { + mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')])); + render(); + await waitFor(() => expect(mockListInstances).toHaveBeenCalledTimes(1)); + await screen.findByTestId('nextcloud-list'); + expect(mockCheckAll).not.toHaveBeenCalled(); + expect(mockCheckOne).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx b/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx new file mode 100644 index 0000000..1fea7f4 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx @@ -0,0 +1,213 @@ +'use client'; + +import Link from 'next/link'; +import { useLocale, useTranslations } from 'next-intl'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import type { WidgetProps } from '@/components/dashboard/widget-registry'; +import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display'; +import { sortClouds } from '@/components/nextcloud-status/sort-clouds'; +// Aus dem Klienten NUR die Leseabfrage (Muster T-I8V-02): die Pruefungen +// (`checkAll`/`checkOne`) gehoeren der Modulseite und duerfen hier nie +// auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen bei den +// Clouds aus. +import { listInstances, type NextcloudList, type RatingLevel } from '@/lib/nextcloud-status-api'; + +/** Fuenf Minuten: so oft liest die Kachel den gespeicherten Stand neu (die Pruefung selbst laeuft stuendlich). */ +const REFRESH_MS = 5 * 60_000; + +const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm'; +const MODULE_HREF = '/modules/nextcloud-status'; + +/** + * NextcloudStatusWidget (quick-261002-k67, L-10) — Ampeluebersicht: drei + * Zaehler (gruen/gelb/rot, „ohne Bewertung“ nur wenn es solche gibt) und + * darunter die roten, dann gelben Clouds mit Name und Grund. + * + * - Liest ausschliesslich `GET /modules/nextcloud-status/instances` + * (serverseitig hinter `@UseModule`), alle 5 Minuten neu; bei verborgenem + * Browser-Tab pausiert der Takt, beim Zurueckkehren laedt die Kachel sofort. + * - Groessenstufen per Container-Query am Kachelrumpf des Wrappers (der ist + * bereits `@container-size`): niedrig oder schmal zeigt nur die Zaehler. + * - Ansichtsmodus: Zaehlerbereich und Zeilen fuehren zur Modulseite. + * Bearbeitungsmodus: Zeilen ohne Ziel und ohne Tabstopp — Links stehen im + * Abbruch-Selektor von `dashboard-grid.tsx`, Anker-Zeilen wuerden das + * Ziehen ueber fast die ganze Kachel blockieren (wie bei Proxmox). + * - Die Kopfzeile (Symbol + Name) setzt der Rahmen (`FRAME_HEADER_TYPES`). + */ +export function NextcloudStatusWidget({ isEditMode }: WidgetProps) { + const t = useTranslations('nextcloudStatus'); + const locale = useLocale(); + + const [data, setData] = useState(null); + // Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-Abhaengigkeiten. + const [loadFailed, setLoadFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + + function load() { + listInstances() + .then((list) => { + if (cancelled) return; + setData(list); + setLoadFailed(false); + }) + .catch(() => { + // Ein schon geladener Stand bleibt stehen; der Fehlersatz erscheint nur, solange es noch nie einen gab. + if (!cancelled) setLoadFailed(true); + }); + } + + function handleVisibilityChange() { + if (document.visibilityState === 'visible') load(); + } + + load(); + const intervalId = setInterval(() => { + if (document.visibilityState !== 'hidden') load(); + }, REFRESH_MS); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + cancelled = true; + clearInterval(intervalId); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, []); + + const counts = useMemo(() => { + const result: Record = { green: 0, yellow: 0, red: 0, unknown: 0 }; + for (const instance of data?.instances ?? []) result[instance.rating.level] += 1; + return result; + }, [data]); + + const attention = useMemo( + () => + sortClouds( + (data?.instances ?? []).filter( + (i) => i.rating.level === 'red' || i.rating.level === 'yellow', + ), + 'status', + ), + [data], + ); + + function frame(body: ReactNode, bodyClassName = 'gap-2') { + return ( +
+
{body}
+
+ ); + } + + function centered(text: string) { + return frame( +

{text}

, + 'items-center justify-center gap-1.5 text-center', + ); + } + + if (data === null && loadFailed) return centered(t('widget.loadError')); + + if (data === null) { + return frame( +
+
+ {t('widget.loading')} +
, + ); + } + + if (data.instances.length === 0) return centered(t('widget.empty')); + + const chips: RatingLevel[] = ['green', 'yellow', 'red']; + if (counts.unknown > 0) chips.push('unknown'); + + const counters = ( +
    + {chips.map((level) => ( +
  • +
  • + ))} +
+ ); + + function row(instance: (typeof attention)[number]): ReactNode { + const style = RATING_STYLE[instance.rating.level]; + const content = ( + <> +