# Phase 5: Dashboard & Calendar - Pattern Map **Mapped:** 2026-06-23 **Files analyzed:** 30 **Analogs found:** 24 / 30 ## File Classification | New/Modified File | Role | Data Flow | Closest Analog | Match Quality | |-------------------|------|-----------|----------------|---------------| | `apps/api/prisma/schema.prisma` (modify) | model | CRUD | selbst | exact | | `apps/api/src/dashboard/dashboard.module.ts` | config | request-response | `apps/api/src/module-registry/module-registry.module.ts` | exact | | `apps/api/src/dashboard/dashboard.controller.ts` | controller | CRUD | `apps/api/src/module-registry/module-registry.controller.ts` | exact | | `apps/api/src/dashboard/dashboard.service.ts` | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` | exact | | `apps/api/src/dashboard/dto/save-layout.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact | | `apps/api/src/dashboard/dto/update-widget-config.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact | | `apps/api/src/dashboard/dto/create-widget.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact | | `apps/api/src/calendar/calendar.module.ts` | config | request-response | `apps/api/src/module-registry/module-registry.module.ts` | exact | | `apps/api/src/calendar/calendar.controller.ts` | controller | CRUD | `apps/api/src/module-registry/module-registry.controller.ts` | exact | | `apps/api/src/calendar/calendar.service.ts` | service | CRUD | `apps/api/src/module-registry/module-registry.service.ts` | role-match | | `apps/api/src/calendar/providers/caldav.provider.ts` | service | request-response | -- | no-analog | | `apps/api/src/calendar/providers/ics.provider.ts` | service | request-response | -- | no-analog | | `apps/api/src/calendar/providers/exchange.provider.ts` | service | request-response | -- | no-analog | | `apps/api/src/calendar/dto/create-calendar-source.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact | | `apps/api/src/calendar/dto/calendar-events-query.dto.ts` | model | transform | `apps/api/src/domaincheck/dto/check-domain.dto.ts` | exact | | `apps/web/src/app/(portal)/page.tsx` (modify) | component | request-response | selbst | exact | | `apps/web/src/app/(portal)/settings/layout.tsx` | component | request-response | `apps/web/src/app/(portal)/layout.tsx` | role-match | | `apps/web/src/app/(portal)/settings/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match | | `apps/web/src/app/(portal)/settings/dashboard/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match | | `apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match | | `apps/web/src/app/(portal)/settings/dashboard/search/page.tsx` | component | request-response | `apps/web/src/app/(portal)/page.tsx` | role-match | | `apps/web/src/components/dashboard/dashboard-grid.tsx` | component | event-driven | -- | no-analog | | `apps/web/src/components/dashboard/edit-mode-toggle.tsx` | component | event-driven | `apps/web/src/components/theme-toggle.tsx` | role-match | | `apps/web/src/components/dashboard/widget-catalog-modal.tsx` | component | event-driven | -- | no-analog | | `apps/web/src/components/dashboard/widgets/clock-widget.tsx` | component | event-driven | -- | no-analog | | `apps/web/src/components/dashboard/widgets/search-widget.tsx` | component | request-response | -- | no-analog | | `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` | component | request-response | -- | no-analog | | `apps/web/src/components/dashboard/widgets/note-widget.tsx` | component | event-driven | -- | no-analog | | `apps/web/src/components/dashboard/widgets/widget-wrapper.tsx` | component | request-response | -- | no-analog | | `apps/web/src/components/settings/settings-sidebar.tsx` | component | request-response | `apps/web/src/components/layout/sidebar.tsx` | role-match | | `apps/web/src/lib/stores/dashboard-store.ts` | store | CRUD | `apps/web/src/lib/stores/sidebar-store.ts` | role-match | | `apps/web/src/components/layout/header.tsx` (modify) | component | request-response | selbst | exact | ## Pattern Assignments ### NestJS Module Pattern (dashboard.module.ts, calendar.module.ts) **Analog:** `apps/api/src/module-registry/module-registry.module.ts` **Vollstaendiges Pattern** (Zeilen 1-22): ```typescript import { Module } from '@nestjs/common'; import { ModuleRegistryController } from './module-registry.controller'; import { ModuleRegistryService } from './module-registry.service'; import { ModuleGuard } from './module.guard'; @Module({ controllers: [ModuleRegistryController], providers: [ModuleRegistryService, ModuleGuard], exports: [ModuleRegistryService, ModuleGuard], }) export class ModuleRegistryModule {} ``` --- ### NestJS Controller Pattern (dashboard.controller.ts, calendar.controller.ts) **Analog:** `apps/api/src/module-registry/module-registry.controller.ts` **Imports Pattern** (Zeilen 1-14): ```typescript import { Controller, ForbiddenException, Get, Param, Post, Req, UseGuards, } from '@nestjs/common'; import { Role } from '@prisma/client'; import { Request } from 'express'; import { Roles } from '../auth/decorators/roles.decorator'; import { RolesGuard } from '../auth/guards/roles.guard'; ``` **Tenant-Context Extraction** (Zeilen 48-53, wiederholt in allen Methoden): ```typescript @Get('active') async findActive(@Req() req: Request) { const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId; if (!tenantId) { throw new ForbiddenException('No tenant context'); } return this.moduleRegistryService.findActiveForTenant(tenantId); } ``` **Hinweis fuer Dashboard:** Dashboard-Endpoints brauchen `userId` zusaetzlich zu `tenantId`. Pattern: `const userId = (req as any).user?.id;` --- ### NestJS Service Pattern (dashboard.service.ts, calendar.service.ts) **Analog:** `apps/api/src/module-registry/module-registry.service.ts` **Imports + Constructor** (Zeilen 1-12): ```typescript import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @Injectable() export class ModuleRegistryService { constructor(private readonly prisma: PrismaService) {} ``` **CRUD Pattern - findMany** (Zeilen 17-21): ```typescript async findAll() { return this.prisma.module.findMany({ orderBy: { name: 'asc' }, }); } ``` **CRUD Pattern - upsert** (Zeilen 53-82): ```typescript async activateForTenant(tenantId: string, moduleId: string) { const moduleExists = await this.prisma.module.findUnique({ where: { id: moduleId }, }); if (!moduleExists) { throw new NotFoundException(`Module with id '${moduleId}' not found`); } return this.prisma.tenantModuleActivation.upsert({ where: { tenantId_moduleId: { tenantId, moduleId } }, update: { isActive: true, activatedAt: new Date() }, create: { tenantId, moduleId, isActive: true }, include: { module: true }, }); } ``` --- ### DTO Pattern (alle dto/*.dto.ts) **Analog:** `apps/api/src/domaincheck/dto/check-domain.dto.ts` **Vollstaendiges Pattern** (Zeilen 1-34): ```typescript import { ArrayMaxSize, IsArray, IsNotEmpty, IsOptional, IsString, Matches, } from 'class-validator'; export class CheckDomainDto { @IsString() @IsNotEmpty() @Matches(/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, { message: 'Domain must be a valid label...', }) domain!: string; @IsOptional() @IsArray() @IsString({ each: true }) @ArrayMaxSize(10, { message: 'Maximum 10 TLDs per request' }) tlds?: string[]; } ``` **Wichtig fuer Dashboard DTOs:** `save-layout.dto.ts` braucht `IsObject` oder `IsJSON` fuer das JSONB layouts-Feld. `class-validator` hat `IsObject()` und `IsNotEmptyObject()`. --- ### Prisma Schema Pattern (schema.prisma erweitern) **Analog:** `apps/api/prisma/schema.prisma` — bestehende Models **Model mit JSONB + userId + tenantId** (Pattern aus User + LdapConfig): ```prisma model LdapConfig { id String @id @default(uuid()) tenantId String @unique tenant Tenant @relation(fields: [tenantId], references: [id]) serverUrl String // ... fields ... isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([tenantId]) } ``` **Neue Models folgen diesem Pattern:** `@id @default(uuid())`, `userId String`, `tenantId String`, `createdAt`/`updatedAt`, `@@index` auf userId und tenantId. JSONB-Felder als `Json` Typ. --- ### Zustand Store Pattern (dashboard-store.ts) **Analog:** `apps/web/src/lib/stores/sidebar-store.ts` **Store mit Persist** (Zeilen 1-21): ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; interface SidebarState { isCollapsed: boolean; isMobileOpen: boolean; toggle: () => void; setMobileOpen: (open: boolean) => void; } export const useSidebarStore = create()( persist( (set) => ({ isCollapsed: false, isMobileOpen: false, toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })), setMobileOpen: (open) => set({ isMobileOpen: open }), }), { name: 'tessera-sidebar' } ) ); ``` **Hinweis:** Dashboard-Store soll NICHT persist verwenden (Layout kommt aus DB, nicht LocalStorage). Einfacher Store ohne persist wie `marketplace-store.ts`: ```typescript import { create } from 'zustand'; export const useMarketplaceStore = create()((set) => ({ // ... })); ``` --- ### Next.js Page Pattern (alle settings pages) **Analog:** `apps/web/src/app/(portal)/page.tsx` **Client Component mit next-intl** (Zeilen 1-6): ```typescript 'use client'; import { useTranslations } from 'next-intl'; export default function DashboardPage() { const t = useTranslations('dashboard'); ``` **Tailwind + OKLCH Farbklassen** (Zeilen 9-11): ```typescript
``` --- ### Next.js Layout Pattern (settings/layout.tsx) **Analog:** `apps/web/src/app/(portal)/layout.tsx` **Nested Layout** (Zeilen 1-13): ```typescript import { AppShell } from '@/components/layout/app-shell'; export default function PortalLayout({ children, }: { children: React.ReactNode; }) { return {children}; } ``` **Settings-Layout wird aehnlich:** Statt `` ein `
` mit `` und Content-Bereich. --- ### Header Modification (Settings-Link hinzufuegen) **Analog:** `apps/web/src/components/layout/header.tsx` **Dropdown-Menu Einfuegepunkt** (Zeilen 130-155): Settings-Link kommt als neues Item zwischen User-Info Block und Logout-Button: ```typescript {/* Logout button - Zeile 132 */}