docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku

- eine Kachel ohne Bauteil (entfernter Typ oder gesperrtes Modul) zeigt
  statt des rohen Typnamens den Satz `widgets.unavailable`, zentriert und
  grau; Schluessel in de.json und en.json
- Entwicklerdoku: neuer Abschnitt "Eine Kachel zum Modul" im
  Modul-Walkthrough — die drei verbliebenen Stellen und was eine Kachel
  mit moduleSlug automatisch tut
- Changelog unter "Unveroeffentlicht - Geaendert"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-22 16:06:25 +02:00
parent 56c07c3581
commit 8be0725577
6 changed files with 78 additions and 7 deletions
+1
View File
@@ -7,6 +7,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
- Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben
## 1.3.0 – 2026-09-22
@@ -3,15 +3,21 @@ import { describe, expect, it, vi } from 'vitest';
// quick-260916-iex: Link-Widget entfernt — unbekannte Widget-Typen (z. B.
// eine alte Link-Kachel vor dem Einspielen der Migration) muessen weiterhin
// ohne Absturz als grauer Text gerendert werden.
// ohne Absturz gerendert werden.
// quick-260922-m1h: Statt des rohen Typnamens steht dort jetzt ein Satz, der
// den Fall erklaert — derselbe Fall tritt kuenftig auf, wenn eine Kachel zu
// einem Modul gehoert, das dem Benutzer nicht freigegeben ist.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useTranslations: () => (key: string) =>
key === 'unavailable'
? 'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.'
: key,
}));
import { WidgetWrapper } from './widget-wrapper';
describe('WidgetWrapper', () => {
it('unbekannter Widget-Typ (z. B. eine alte Link-Kachel vor der Migration) rendert als grauer Text ohne Absturz', () => {
it('unbekannter/gesperrter Widget-Typ erklaert sich mit einem Hinweistext statt leer oder als roher Typname zu rendern', () => {
render(
<WidgetWrapper
widget={{ id: 'w-alt', widgetType: 'link', config: {} }}
@@ -23,7 +29,29 @@ describe('WidgetWrapper', () => {
const article = screen.getByRole('article');
expect(article).toHaveAttribute('aria-label', 'link');
const fallback = screen.getByText('link');
expect(fallback.className).toContain('text-muted-foreground');
const hint = screen.getByText(
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
);
expect(hint.className).toContain('text-muted-foreground');
expect(hint.className).toContain('text-center');
// Der rohe Typname steht nicht mehr im Rumpf der Kachel.
expect(screen.queryByText('link')).toBeNull();
});
it('eine bekannte Kachel rendert weiterhin ihre Komponente, nicht den Hinweis', () => {
render(
<WidgetWrapper
widget={{ id: 'w-uhr', widgetType: 'clock', config: {} }}
isEditMode={false}
onRemove={vi.fn()}
/>,
);
expect(
screen.queryByText(
'Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.',
),
).toBeNull();
});
});
@@ -111,8 +111,14 @@ export function WidgetWrapper({ widget, isEditMode, onRemove }: WidgetWrapperPro
isEditMode={isEditMode}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{widget.widgetType}
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
stand hier der rohe Typname, der dem Anwender nichts sagte. */
<div className="flex h-full items-center justify-center p-3">
<p className="text-center text-sm text-muted-foreground">
{t('unavailable')}
</p>
</div>
)}
</div>
+1
View File
@@ -213,6 +213,7 @@
"saveChanges": "Änderungen speichern",
"layoutLoadError": "Dashboard konnte nicht geladen werden. Bitte laden Sie die Seite neu.",
"widgetSaveError": "Änderungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.",
"unavailable": "Diese Kachel steht nicht zur Verfügung — das zugehörige Modul ist nicht freigegeben.",
"clock": {
"name": "Uhr",
"description": "Zeigt die aktuelle Uhrzeit an",
+1
View File
@@ -213,6 +213,7 @@
"saveChanges": "Save changes",
"layoutLoadError": "Could not load dashboard. Please reload the page.",
"widgetSaveError": "Could not save changes. Please try again.",
"unavailable": "This tile is not available — the module it belongs to is not enabled for you.",
"clock": {
"name": "Clock",
"description": "Shows the current time",
+34
View File
@@ -400,6 +400,40 @@ Für ein Modul mit Unterrouten (Einstellungsseite, Verwaltungsansicht) orientier
`dkv-fleet` oder `tender-radar` — beide haben zusätzliche `settings/page.tsx` bzw. weitere
Unterverzeichnisse, die vom selben `layout.tsx` mitgedeckt werden.
### Eine Kachel zum Modul
Ein Modul kann zusätzlich als Kachel auf dem Dashboard erscheinen. Seit
`quick-260922-m1h` sind dafür **drei** Stellen nötig (vorher waren es sieben):
1. **Die Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
nimmt die `WidgetProps` aus `widget-registry.tsx` (`instanceId`, `config`, `isEditMode`) entgegen.
2. **Den Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehört die Kachel zu
einem Modul, zusätzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei. Das ist
die einzige Liste: die API validiert `POST /dashboard/widgets` per `@IsIn` gegen genau sie, und
das Frontend leitet Registry und Katalog davon ab.
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`,
neben den übrigen Aufrufen. Der Aufruf steht dort und nicht in der Registry, weil die Kachel über
den Wrapper wieder die Registry importiert — ein Import aus der Registry heraus wäre ein
Zirkelimport.
Dazu kommen wie bei jeder Oberfläche die **Übersetzungsschlüssel** (`<typ>.name` und
`<typ>.description` unter `widgets` in `de.json` **und** `en.json`), ein **Symbol** als Inline-SVG
und die **Größenvorgaben** in `WIDGET_CONSTRAINTS` (`minW`/`minH` = kleinste noch bedienbare Kachel,
`defaultW`/`defaultH` = Startgröße) — beides in `widget-registry.tsx`. Ein Test in
`widget-registry.test.tsx` prüft, dass `WIDGET_TYPES`, Registry und Constraints deckungsgleich sind;
vergisst man eine Stelle, schlägt er fehl.
**Was eine Kachel mit `moduleSlug` automatisch tut:** Sie verschwindet für Benutzer, die das Modul
nicht nutzen dürfen — aus dem Katalog („Widget hinzufügen", `visibleWidgetTypes`) und aus dem
Dashboard selbst. Ist die Modulliste unbekannt, weil ihr Abruf fehlschlug, bleibt die Kachel
ebenfalls verborgen (fail-closed). Eine bereits angelegte Kachel eines gesperrten Moduls rendert
nicht mehr leer, sondern zeigt den Hinweis `widgets.unavailable`.
**Wie beim Modul-Gate gilt auch hier:** Der Katalogfilter ist Komfort, nicht Zugriffskontrolle. Die
verbindliche Prüfung sitzt serverseitig in `DashboardService.getWidgets()` (filtert Kacheln
gesperrter Module fail-closed aus `GET /dashboard/widgets`) — und die Daten, die eine Modul-Kachel
anzeigt, holt sie über die Endpunkte ihres Moduls, die `@UseModule('<slug>')` tragen müssen.
## Mandantentrennung
Der tatsächliche Mechanismus ist `TenantGuard` (`apps/api/src/tenant/tenant.guard.ts`), global als