feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog

- Kachel mit Zählern und roten/gelben Clouds, nur mit Modulzugriff sichtbar (geteilte Widget-Typen, Modulzuordnung, Katalog)
- Anleitung für Anwender und Administratoren, Eintrag im Changelog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:00:31 +02:00
parent 6698ef1a92
commit 87a7b7cbcd
18 changed files with 564 additions and 27 deletions
+2
View File
@@ -6,6 +6,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Neu ### 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. - 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. - 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. - 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.
@@ -21,14 +21,26 @@ describe('widget-module-map (quick-260922-m1h)', () => {
} }
}); });
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle // quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel,
// uebrigen bleiben Plattform-Kacheln ohne Modulbezug. // quick-261002-k67 ergaenzt Nextcloud-Status; alle uebrigen bleiben
it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => { // Plattform-Kacheln ohne Modulbezug.
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) { 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(); 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)", () => { it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox'); expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
}); });
+6 -5
View File
@@ -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 * 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). * diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
* *
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` → * Seit quick-261002-k67 stehen dort zwei Einträge: `proxmox` → `proxmox`
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/ * (quick-260924-i8v) und `nextcloud-status` → `nextcloud-status`. Die
* calculator/favorites/stopwatch/picture-frame/xframe) sind * übrigen Widget-Typen (clock/search/calendar/note/calculator/favorites/
* Plattform-Widgets ohne Modulbezug. * stopwatch/picture-frame/xframe/reminder) sind Plattform-Widgets ohne
* Modulbezug.
*/ */
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS; export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
/** /**
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn * 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. * damit Tests sie gezielt mocken können.
*/ */
export function getModuleSlugForWidgetType(widgetType: string): string | undefined { export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
+3
View File
@@ -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/picture-frame-widget', () => ({ PictureFrameWidget: () => null }));
vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null })); vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }));
vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null })); vi.mock('@/components/dashboard/widgets/proxmox-widget', () => ({ ProxmoxWidget: () => null }));
vi.mock('@/components/dashboard/widgets/nextcloud-status-widget', () => ({
NextcloudStatusWidget: () => null,
}));
beforeEach(() => { beforeEach(() => {
catalogProps.accessibleModuleSlugs = undefined; catalogProps.accessibleModuleSlugs = undefined;
+2
View File
@@ -18,6 +18,7 @@ import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widg
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-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 { NoteWidget } from '@/components/dashboard/widgets/note-widget';
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget'; import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget'; import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
@@ -45,6 +46,7 @@ registerWidget('picture-frame', PictureFrameWidget);
registerWidget('xframe', XframeWidget); registerWidget('xframe', XframeWidget);
registerWidget('proxmox', ProxmoxWidget); registerWidget('proxmox', ProxmoxWidget);
registerWidget('reminder', ReminderWidget); registerWidget('reminder', ReminderWidget);
registerWidget('nextcloud-status', NextcloudStatusWidget);
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */ /** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
interface ActiveModule { interface ActiveModule {
@@ -29,6 +29,8 @@ vi.mock('next-intl', () => ({
'xframe.description': 'Webseite einbetten', 'xframe.description': 'Webseite einbetten',
'proxmox.name': 'Proxmox', 'proxmox.name': 'Proxmox',
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick', 'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
'nextcloudStatus.name': 'Nextcloud-Status',
'nextcloudStatus.description': 'Ampelübersicht Ihrer Nextcloud-Clouds',
}, },
common: { common: {
close: 'Schließen', close: 'Schließen',
@@ -44,7 +46,8 @@ import { WidgetCatalogModal } from './widget-catalog-modal';
// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei // Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei
// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff // 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 = { const baseProps = {
isOpen: true, isOpen: true,
onClose: vi.fn(), onClose: vi.fn(),
@@ -151,18 +154,33 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
).map((c) => c.getAttribute('data-widget-type')); ).map((c) => c.getAttribute('data-widget-type'));
} }
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => { it("zeigt mit Zugriff auf beide Module alle zwoelf Kacheln in der Reihenfolge der Registry, Nextcloud-Status zuletzt", () => {
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />); render(
<WidgetCatalogModal
{...baseProps}
accessibleModuleSlugs={['proxmox', 'nextcloud-status']}
/>,
);
expect(catalogTypes()).toEqual([...WIDGET_TYPES]); 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]); 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(<WidgetCatalogModal {...baseProps} />); render(<WidgetCatalogModal {...baseProps} />);
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(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['nextcloud-status']} />);
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', () => { it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
@@ -35,6 +35,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
'proxmox', 'proxmox',
// Erinnerungen (quick-260929-if2) // Erinnerungen (quick-260929-if2)
'reminder', 'reminder',
// Nextcloud-Status — zweite Modul-Kachel (quick-261002-k67)
'nextcloud-status',
]; ];
describe('WIDGET_CONSTRAINTS (DASH-11)', () => { describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
@@ -72,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); 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 // Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche // Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW // 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 }, xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 }, proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 },
}); });
let counted = 0; let counted = 0;
@@ -99,7 +102,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
counted++; 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]); 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); 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) { for (const type of WIDGET_TYPES) {
if (type === 'proxmox') { if (type === 'proxmox') {
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox'); expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox');
} else if (type === 'nextcloud-status') {
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('nextcloud-status');
} else { } else {
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined(); expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
} }
@@ -229,14 +234,22 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
note: {}, 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( 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)", () => { 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'])).toEqual([...WIDGET_TYPES]); 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', () => { it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
@@ -20,6 +20,7 @@ import type { ComponentType } from 'react';
* xframe: Webseite als Rahmen (quick-260921-qd3). * xframe: Webseite als Rahmen (quick-260921-qd3).
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v). * proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2). * reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
* nextcloud-status: Ampeluebersicht der Nextcloud-Clouds, zweite Modul-Kachel (quick-261002-k67).
*/ */
export type { WidgetType }; export type { WidgetType };
@@ -102,6 +103,12 @@ export const WIDGET_CONSTRAINTS: Record<
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 = // 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
// rund 340x270 px: drei bis vier Erinnerungen auf einmal. // rund 340x270 px: drei bis vier Erinnerungen auf einmal.
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }, 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 * Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel * `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; moduleSlug?: string;
/** Inline SVG icon as React component */ /** Inline SVG icon as React component */
@@ -410,6 +417,26 @@ function ReminderIcon({ className }: { className?: string }) {
); );
} }
function NextcloudStatusIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
);
}
/** /**
* Registry of all widget types. Used by the widget catalog modal * Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints. * and the grid renderer to look up components and constraints.
@@ -516,6 +543,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
moduleSlug: WIDGET_MODULE_SLUGS.reminder, moduleSlug: WIDGET_MODULE_SLUGS.reminder,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt 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
},
}; };
/** /**
@@ -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(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
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 }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
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(<NextcloudStatusWidget {...props} />);
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(<NextcloudStatusWidget {...props} />);
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(<NextcloudStatusWidget {...props} />);
expect(await screen.findByText('Noch keine Cloud eingetragen')).toBeInTheDocument();
});
it('Ladefehler zeigt den Fehlersatz', async () => {
mockListInstances.mockRejectedValue(new Error('boom'));
render(<NextcloudStatusWidget {...props} />);
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(<NextcloudStatusWidget {...props} />);
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(<NextcloudStatusWidget {...props} />);
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(<NextcloudStatusWidget {...props} isEditMode />);
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(<NextcloudStatusWidget {...props} />);
await waitFor(() => expect(mockListInstances).toHaveBeenCalledTimes(1));
await screen.findByTestId('nextcloud-list');
expect(mockCheckAll).not.toHaveBeenCalled();
expect(mockCheckOne).not.toHaveBeenCalled();
});
});
@@ -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<NextcloudList | null>(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<RatingLevel, number> = { 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 (
<div data-testid="nextcloud-status-widget" className="flex h-full flex-col overflow-hidden">
<div className={`flex min-h-0 flex-1 flex-col p-2.5 ${bodyClassName}`}>{body}</div>
</div>
);
}
function centered(text: string) {
return frame(
<p className="text-sm text-muted-foreground">{text}</p>,
'items-center justify-center gap-1.5 text-center',
);
}
if (data === null && loadFailed) return centered(t('widget.loadError'));
if (data === null) {
return frame(
<div aria-busy="true">
<div className="h-6 w-full animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
<span className="sr-only">{t('widget.loading')}</span>
</div>,
);
}
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 = (
<ul
data-testid="nextcloud-counters"
aria-label={t('widget.countsLabel')}
className="flex shrink-0 flex-wrap gap-1.5"
>
{chips.map((level) => (
<li
key={level}
data-level={level}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${RATING_STYLE[level].pill}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${RATING_STYLE[level].fill}`}
/>
<span className="tabular-nums" data-testid={`nextcloud-count-${level}`}>
{counts[level]}
</span>
{/* Die Ampelfarbe haengt nie nur an der Farbe: das Wort steht immer dabei. */}
<span>{t(`level.${level}`)}</span>
</li>
))}
</ul>
);
function row(instance: (typeof attention)[number]): ReactNode {
const style = RATING_STYLE[instance.rating.level];
const content = (
<>
<span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${style.fill}`} />
<span className="min-w-0 flex-1 truncate">{instance.customerName}</span>
<span
className={`shrink-0 truncate text-right text-xs @max-[15rem]:hidden ${style.text}`}
data-testid="nextcloud-row-reason"
>
{ratingReasonText(t, instance.rating, locale)}
</span>
</>
);
return (
<li key={instance.id} data-testid="nextcloud-row" data-level={instance.rating.level}>
{isEditMode ? (
<div className={ROW_CLASS}>{content}</div>
) : (
<Link
href={MODULE_HREF}
className={`${ROW_CLASS} transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>
{content}
</Link>
)}
</li>
);
}
const allGood = counts.yellow === 0 && counts.red === 0 && counts.unknown === 0;
return frame(
<>
{isEditMode ? (
counters
) : (
<Link
href={MODULE_HREF}
aria-label={t('widget.open')}
className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{counters}
</Link>
)}
{allGood ? (
<p className="shrink-0 truncate text-sm font-medium text-status-ok-fg [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden">
{t('widget.allGood')}
</p>
) : (
attention.length > 0 && (
<ul
data-testid="nextcloud-list"
className="-mx-1 min-h-0 flex-1 overflow-y-auto [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden"
>
{attention.map((instance) => row(instance))}
</ul>
)
)}
</>,
);
}
@@ -67,6 +67,7 @@ const PATHS: Record<string, ReactNode> = {
<path d="M6 6h.01M6 18h.01" /> <path d="M6 6h.01M6 18h.01" />
</> </>
), ),
'nextcloud-status': <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
reminder: ( reminder: (
<> <>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
@@ -9,12 +9,20 @@ import { WidgetIcon } from './widget-icon';
/** /**
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche * Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit * Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
* quick-260929-if2). Notiz, * quick-260929-if2; Nextcloud-Status seit quick-261002-k67 — die Kachel hat
* keinen eigenen Titel, nur Zaehler und Liste). Notiz,
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel) * Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw. * mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* ein Foto braucht keine Ueberschrift. * ein Foto braucht keine Ueberschrift.
*/ */
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']); const FRAME_HEADER_TYPES = new Set([
'calendar',
'clock',
'calculator',
'stopwatch',
'reminder',
'nextcloud-status',
]);
/** /**
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den * Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
+12
View File
@@ -521,6 +521,10 @@
"emptyState": { "emptyState": {
"title": "Ihr Dashboard ist noch leer", "title": "Ihr Dashboard ist noch leer",
"body": "Stellen Sie sich Ihre Startseite aus Kacheln zusammen. Zum Beispiel:" "body": "Stellen Sie sich Ihre Startseite aus Kacheln zusammen. Zum Beispiel:"
},
"nextcloudStatus": {
"name": "Nextcloud-Status",
"description": "Ampelübersicht Ihrer Nextcloud-Clouds"
} }
}, },
"admin": { "admin": {
@@ -1895,6 +1899,14 @@
"saveError": "Die Cloud konnte nicht gespeichert werden.", "saveError": "Die Cloud konnte nicht gespeichert werden.",
"deleteError": "Die Cloud konnte nicht entfernt werden.", "deleteError": "Die Cloud konnte nicht entfernt werden.",
"close": "Dialog schließen" "close": "Dialog schließen"
},
"widget": {
"loading": "Status wird geladen …",
"loadError": "Status konnte nicht geladen werden",
"empty": "Noch keine Cloud eingetragen",
"allGood": "Alle Clouds sind aktuell",
"countsLabel": "Anzahl der Clouds je Ampelfarbe",
"open": "Nextcloud-Status öffnen"
} }
} }
} }
+12
View File
@@ -521,6 +521,10 @@
"emptyState": { "emptyState": {
"title": "Your dashboard is still empty", "title": "Your dashboard is still empty",
"body": "Build your start page from tiles. For example:" "body": "Build your start page from tiles. For example:"
},
"nextcloudStatus": {
"name": "Nextcloud status",
"description": "Traffic-light overview of your Nextcloud clouds"
} }
}, },
"admin": { "admin": {
@@ -1895,6 +1899,14 @@
"saveError": "The cloud could not be saved.", "saveError": "The cloud could not be saved.",
"deleteError": "The cloud could not be removed.", "deleteError": "The cloud could not be removed.",
"close": "Close dialog" "close": "Close dialog"
},
"widget": {
"loading": "Loading status …",
"loadError": "Status could not be loaded",
"empty": "No cloud added yet",
"allGood": "All clouds are up to date",
"countsLabel": "Number of clouds per traffic-light color",
"open": "Open Nextcloud status"
} }
} }
} }
@@ -106,6 +106,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch) // quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste', 'Neueste',
'ausstehend', 'ausstehend',
'aktuell',
'Statusseite', 'Statusseite',
'Bildadresse', 'Bildadresse',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch) // quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
+11
View File
@@ -250,6 +250,7 @@ Was „Verwalten“ je Modul freischaltet:
| Kantinenabrechnung | Reiter „Einstellungen“ (Beraternummer, Mandantennummer, Lohnart) | | Kantinenabrechnung | Reiter „Einstellungen“ (Beraternummer, Mandantennummer, Lohnart) |
| Handelsware | Reiter „Einstellungen“ (Standard-Erlöskonto, Startwert Gegenkonto) | | Handelsware | Reiter „Einstellungen“ (Standard-Erlöskonto, Startwert Gegenkonto) |
| Proxmox | Server anlegen, ändern und löschen, Verbindung prüfen, „Jetzt aktualisieren“ | | Proxmox | Server anlegen, ändern und löschen, Verbindung prüfen, „Jetzt aktualisieren“ |
| Nextcloud-Status | Clouds eintragen, ändern und entfernen, Logos hinterlegen, „Jetzt prüfen“ |
| DKV-Rechnung | das gesamte Modul – „Benutzen“ allein reicht dort nicht; Benutzer mit nur „Benutzen“ sehen eine Seite, die das erklärt | | DKV-Rechnung | das gesamte Modul – „Benutzen“ allein reicht dort nicht; Benutzer mit nur „Benutzen“ sehen eine Seite, die das erklärt |
Hat jemand über mehrere Wege Zugriff (zum Beispiel direkt „Benutzen“ und über eine Gruppe „Verwalten“), gilt die **höhere** Stufe. Hat jemand über mehrere Wege Zugriff (zum Beispiel direkt „Benutzen“ und über eine Gruppe „Verwalten“), gilt die **höhere** Stufe.
@@ -331,6 +332,16 @@ In Tessera: Typ **PMG**, Adresse z. B. `https://pmg01.intern:8006`, Zugangsart *
Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen. Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen.
### Nextcloud-Status: Clouds eintragen
Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Clouds eintragen, ändern, entfernen, Logos hinterlegen und „Jetzt prüfen“** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ – vergeben Sie diese Stufe nur an Personen, denen Sie das zutrauen, denn eingetragene Adressen ruft der Tessera-Server selbst ab.
- **Eintragen:** Kundenname und Adresse der Cloud (zum Beispiel `https://cloud.kunde.de`; ein Zusatz wie `/status.php` oder `/index.php` wird entfernt, ein Unterpfad wie `/nextcloud` bleibt). Zugangsdaten werden nicht gebraucht und nicht gespeichert: Tessera ruft nur die öffentliche Seite `<Adresse>/status.php` ab und zeigt davon Version, Wartungsmodus und Erreichbarkeit.
- **Netzwerk:** Der API-Container braucht ausgehenden Zugriff auf die eingetragenen Clouds und auf `endoflife.date` (Quelle der Versionsdaten). Interne Adressen sind ausdrücklich erlaubt. Pro Abruf gelten ein Zeitlimit von 10 Sekunden, höchstens drei Weiterleitungen und eine Größenbegrenzung der Antwort.
- **Zertifikat:** Das Zertifikat der Cloud wird geprüft. Ein abgelaufenes oder nicht vertrauenswürdiges Zertifikat erscheint als „Nicht erreichbar“, der Fehlercode steht klein darunter.
- **Rhythmus:** Tessera prüft jede Cloud einmal pro Stunde, auch nach einem Neustart und auf einer frischen Datenbank. Die Versionsdaten werden höchstens alle 12 Stunden neu geholt; ist `endoflife.date` nicht erreichbar, bleiben die zuletzt abgerufenen Daten erhalten, und ohne Daten zeigen die Kacheln grau „Bewertung nicht möglich“.
- **Logo:** Entweder ein hochgeladenes Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB; SVG wird bewusst nicht angenommen) oder die https-Adresse eines Bildes, das der Browser des Betrachters direkt lädt – der Server ruft diese Adresse nie ab. Beides zugleich gibt es nicht: ein Upload ersetzt die Adresse und umgekehrt.
## 6. SMTP ## 6. SMTP
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde. Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
+24
View File
@@ -16,6 +16,7 @@ Diese Anleitung richtet sich an alle Kolleginnen und Kollegen, die Tessera im Ar
- [Zertifikat-Manager](#zertifikat-manager) - [Zertifikat-Manager](#zertifikat-manager)
- [Domaincheck](#domaincheck) - [Domaincheck](#domaincheck)
- [Proxmox](#proxmox) - [Proxmox](#proxmox)
- [Nextcloud-Status](#nextcloud-status)
- [Kantinenabrechnung](#kantinenabrechnung) - [Kantinenabrechnung](#kantinenabrechnung)
- [Handelsware](#handelsware) - [Handelsware](#handelsware)
7. [Persönliche Einstellungen](#persönliche-einstellungen) 7. [Persönliche Einstellungen](#persönliche-einstellungen)
@@ -175,6 +176,29 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
**Die Dashboard-Kachel „Proxmox“:** Wer das Modul nutzen darf, kann sich den Zustand der Server auch als Kachel auf das Dashboard legen („Widget hinzufügen“, ganz unten im Katalog). Sie zeigt den farbigen Balken der Modulseite in klein, darunter in Worten, ob alles in Ordnung ist, und die Server mit je einer Kennzahl; ein Klick auf einen Server öffnet diese Seite. Die Kachel liest jede Minute den zuletzt gespeicherten Stand und löst selbst nie eine Abfrage bei Proxmox aus — wie aktuell die Werte sind, bestimmt weiterhin der Abfrageabstand je Server. Welche Server die Kachel zeigt, stellen Sie im Bearbeitungsmodus an der Kachel oder unter Einstellungen > Dashboard ein. **Die Dashboard-Kachel „Proxmox“:** Wer das Modul nutzen darf, kann sich den Zustand der Server auch als Kachel auf das Dashboard legen („Widget hinzufügen“, ganz unten im Katalog). Sie zeigt den farbigen Balken der Modulseite in klein, darunter in Worten, ob alles in Ordnung ist, und die Server mit je einer Kennzahl; ein Klick auf einen Server öffnet diese Seite. Die Kachel liest jede Minute den zuletzt gespeicherten Stand und löst selbst nie eine Abfrage bei Proxmox aus — wie aktuell die Werte sind, bestimmt weiterhin der Abfrageabstand je Server. Welche Server die Kachel zeigt, stellen Sie im Bearbeitungsmodus an der Kachel oder unter Einstellungen > Dashboard ein.
### Nextcloud-Status
Das Modul zeigt, ob die Nextcloud-Clouds Ihrer Kunden erreichbar und auf einem aktuellen Stand sind. Es steht in der Seitenleiste in der Gruppe **Infrastruktur**. Tessera ruft dafür nur die öffentliche Statusseite der Cloud ab; eine Anmeldung in der Cloud ist nicht nötig, und es wird dort nichts verändert.
**Die Kacheln:** Jede eingetragene Cloud erscheint als Kachel mit Logo (ohne Logo die Anfangsbuchstaben des Kundennamens), Kundenname, Adresse (ein Klick öffnet die Cloud in einem neuen Tab), installierter Version, der Ampelfarbe mit einer kurzen Begründung („Aktuell“, „Update auf 34.0.4 verfügbar“, „Support endet am 30.06.2027“, „Support abgelaufen seit …“, „Nicht erreichbar“, „Wartungsmodus“) und dem Zeitpunkt der letzten Prüfung. Oberhalb der Kacheln steht die neueste Nextcloud-Version.
**Was die Farben bedeuten:**
| Farbe | Bedeutung |
|---|---|
| Grün | Die Cloud hat den neuesten Stand ihrer Nextcloud-Version, und der Support dieser Version endet erst in mehr als drei Monaten. |
| Gelb | Für die Version der Cloud gibt es ein Update, **oder** der Support dieser Version endet innerhalb der nächsten drei Monate (der Tag des Support-Endes selbst zählt noch als gelb). |
| Rot | Der Support der Version ist abgelaufen, **oder** die Cloud ist nicht erreichbar bzw. antwortet nicht wie eine Nextcloud, **oder** sie steht im Wartungsmodus, **oder** eine Datenbank-Aktualisierung steht aus. Eine solche Meldung gilt vor allem anderen. |
| Grau | „Bewertung nicht möglich“: Tessera konnte die Versionsdaten (von endoflife.date) noch nicht abrufen oder kennt die Version nicht. Die Version wird trotzdem angezeigt. Rote Zustände (nicht erreichbar, Wartungsmodus, Datenbank-Aktualisierung) bleiben auch dann rot. |
**Wie aktuell die Anzeige ist:** Tessera prüft jede Cloud automatisch **einmal pro Stunde**. Wer die Cloud verwalten darf, kann mit „Jetzt prüfen“ alle Clouds sofort prüfen oder mit dem Knopf auf einer Kachel nur diese eine. Die Versionsdaten holt Tessera höchstens alle 12 Stunden neu; ist die Quelle einmal nicht erreichbar, bleibt der zuletzt bekannte Stand erhalten.
**Sortieren:** Über „Sortieren nach“ ordnen Sie die Kacheln nach Kundenname (A–Z), Status (Rot zuerst), Version (älteste zuerst) oder Support-Ende (frühestes zuerst; Clouds ohne Angabe stehen am Ende). Ihre Auswahl merkt sich Tessera für Sie, auch nach dem Neuladen.
**Clouds pflegen** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“: „Cloud hinzufügen“ (Kundenname und Adresse, optional ein Logo), Bearbeiten und Entfernen über die Kachel. Beim Logo haben Sie die Wahl zwischen einem hochgeladenen Bild (PNG, JPEG, GIF oder WebP, höchstens 1 MB) und der https-Adresse eines Bildes, das Ihr Browser selbst lädt. Wer nur die Stufe „Benutzen“ hat, sieht die Kacheln und kann sortieren, aber nichts ändern.
**Die Dashboard-Kachel „Nextcloud-Status“:** Wer das Modul nutzen darf, kann sich eine Übersicht auch auf das Dashboard legen („Widget hinzufügen“). Sie zeigt drei Zähler (Grün, Gelb, Rot, bei Bedarf „Ohne Bewertung“) und darunter die roten und gelben Clouds mit Kundenname und Grund; sind alle Clouds in Ordnung, steht dort eine kurze Bestätigung. Ein Klick öffnet das Modul. Die Kachel liest nur den gespeicherten Stand und löst selbst keine Prüfung aus.
### Kantinenabrechnung ### Kantinenabrechnung
Das Modul bereitet die Kantinenabrechnung für die Gehaltsabrechnung vor. Es steht in der Seitenleiste in der Gruppe **Finanzbuchhaltung**. Sie laden die CSV-Datei der Kantine hoch, Tessera prüft sie und erstellt daraus die Lohndatei für DATEV. Das Modul bereitet die Kantinenabrechnung für die Gehaltsabrechnung vor. Es steht in der Seitenleiste in der Gruppe **Finanzbuchhaltung**. Sie laden die CSV-Datei der Kantine hoch, Tessera prüft sie und erstellt daraus die Lohndatei für DATEV.
+7 -1
View File
@@ -111,6 +111,7 @@ export const WIDGET_TYPES = [
'xframe', 'xframe',
'proxmox', 'proxmox',
'reminder', 'reminder',
'nextcloud-status',
] as const; ] as const;
export type WidgetType = (typeof WIDGET_TYPES)[number]; export type WidgetType = (typeof WIDGET_TYPES)[number];
@@ -124,10 +125,15 @@ export type WidgetType = (typeof WIDGET_TYPES)[number];
* Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne * Die neun Kacheln von vor quick-260924-i8v sind Plattform-Kacheln ohne
* Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist * Modulbezug. Proxmox ist die erste modulgebundene Kachel; ihr Slug ist
* derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'` * derselbe wie `@UseModule('proxmox')` im Controller und `slug: 'proxmox'`
* in `proxmox.seed.ts`. * in `proxmox.seed.ts`. Die zweite modulgebundene Kachel ist Nextcloud-Status
* (quick-261002-k67); auch hier ist der Slug identisch mit `slug:
* 'nextcloud-status'` in `nextcloud-status.seed.ts` und dem klassenweiten
* `@UseModule('nextcloud-status')` im Controller. Reminder (Erinnerungen)
* bleibt eine Plattform-Kachel.
*/ */
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = { export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {
proxmox: 'proxmox', proxmox: 'proxmox',
'nextcloud-status': 'nextcloud-status',
}; };
/** /**