Compare commits

...

6 Commits

Author SHA1 Message Date
m
0712d9a367 docs: design document for dashboard redesign + detail pages (t-kz-060)
Comprehensive design covering:
- Dashboard interactivity (click-to-filter traffic lights, clickable timeline,
  fixed quick actions, AI summary refresh)
- New detail pages (deadline, appointment, case event)
- Notes system with polymorphic table design
- Case detail URL-based tab navigation
- Breadcrumb navigation system
- Backend API additions and data model changes
- Phased implementation plan for coders
2026-03-25 18:49:48 +01:00
m
cd31e76d07 fix: TenantSwitcher shows dropdown for single tenant, wider name display 2026-03-25 18:40:15 +01:00
m
f42b7ddec7 fix: add array guards to all frontend components consuming API responses 2026-03-25 18:35:28 +01:00
m
50bfa3deb4 fix: add array guards to all frontend components consuming API responses
Prevents "M.forEach is not a function" crashes when API returns error
objects or unexpected shapes instead of arrays. Guards all useQuery
consumers with Array.isArray checks and safe defaults for object props.

Files fixed: DeadlineList, AppointmentList, TenantSwitcher,
DeadlineTrafficLights, UpcomingTimeline, CaseOverviewGrid,
AISummaryCard, TeamSettings, and all page-level components
(dashboard, cases, fristen, termine, ai/extract).
2026-03-25 18:34:11 +01:00
m
e635efa71e fix: remove remaining /api/ double-prefix from template literal API calls
Previous fix missed backtick template strings. Fixed 7 more api.*()
calls in appointments, deadlines, settings, and einstellungen pages.
2026-03-25 18:20:35 +01:00
m
12e0407025 test: comprehensive E2E and API test suite for full KanzlAI stack 2026-03-25 16:21:32 +01:00
19 changed files with 722 additions and 45 deletions

View File

@@ -0,0 +1,665 @@
# Design: Dashboard Redesign + Detail Pages
**Task:** t-kz-060
**Author:** cronus (inventor)
**Date:** 2026-03-25
**Status:** Design proposal
---
## Problem Statement
The current dashboard is a read-only status board. Cards show counts but don't link anywhere. Timeline items are inert. Quick actions navigate to list pages rather than creation flows. There are no detail pages for individual events, deadlines, or appointments. Notes don't exist as a first-class entity. Case detail tabs use local state instead of URL segments, breaking deep linking and back navigation.
## Design Principles
1. **Everything clickable goes somewhere** — no dead-end UI
2. **Breadcrumb navigation** — always know where you are, one click to go back
3. **German labels throughout** — consistent with existing convention
4. **Mobile responsive** — sidebar collapses, cards stack, touch targets >= 44px
5. **Information density over whitespace** — law firm users want data, not decoration
6. **URL-driven state** — tabs, filters, and views reflected in the URL for deep linking
---
## Part 1: Dashboard Redesign
### 1.1 Traffic Light Cards → Click-to-Filter
**Current:** Three cards (Ueberfaellig / Diese Woche / Im Zeitplan) show counts. `onFilter` prop exists but is never wired up in `dashboard/page.tsx`.
**Proposed:**
Clicking a traffic light card navigates to `/fristen?status={filter}`:
| Card | Navigation Target |
|------|------------------|
| Ueberfaellig (red) | `/fristen?status=overdue` |
| Diese Woche (amber) | `/fristen?status=this_week` |
| Im Zeitplan (green) | `/fristen?status=ok` |
**Implementation:**
- Replace `onFilter` callback with `next/link` navigation using `href`
- `DeadlineTrafficLights` becomes a pure link-based component (no callback needed)
- `/fristen` page reads `searchParams.status` and pre-applies the filter
- The DeadlineList component already supports status filtering — just needs to read from URL
**Changes:**
- `DeadlineTrafficLights.tsx`: Replace `<button onClick>` with `<Link href="/fristen?status=...">`
- `fristen/page.tsx`: Read `searchParams` and pass as initial filter to DeadlineList
- `DeadlineList.tsx`: Accept `initialStatus` prop from URL params
### 1.2 Case Overview Grid → Click-to-Filter
**Current:** Three static metrics (Aktive Akten / Neu / Abgeschlossen). No links.
**Proposed:**
| Card | Navigation Target |
|------|------------------|
| Aktive Akten | `/cases?status=active` |
| Neu (Monat) | `/cases?status=active&since=month` |
| Abgeschlossen | `/cases?status=closed` |
**Implementation:**
- Wrap each metric row in `<Link>` to `/cases` with appropriate query params
- Cases list page already has filtering — needs to read URL params on mount
- Add visual hover state (arrow icon on hover, background highlight)
**Changes:**
- `CaseOverviewGrid.tsx`: Each row becomes a `<Link>` with hover arrow
- `cases/page.tsx`: Read `searchParams` for initial filter state
### 1.3 Timeline Items → Click-to-Navigate
**Current:** Timeline entries show deadline/appointment info but are not clickable. No link to the parent case or the item itself.
**Proposed:**
Each timeline entry becomes a clickable row:
- **Deadline entries**: Click navigates to `/fristen/{id}` (new deadline detail page)
- **Appointment entries**: Click navigates to `/termine/{id}` (new appointment detail page)
- **Case reference** (Az. / case_number): Secondary click target linking to `/cases/{case_id}`
**Visual changes:**
- Add `cursor-pointer` and hover state (`hover:bg-neutral-100` transition)
- Add a small chevron-right icon on the right edge
- Case number becomes a subtle underlined link (click stops propagation)
**Data changes:**
- `UpcomingDeadline` needs `case_id` field (currently missing from the dashboard query — the backend model has it but the SQL join doesn't select it)
- `UpcomingAppointment` already has `case_id`
**Backend change:**
- `dashboard_service.go` line 112: Add `d.case_id` to the upcoming deadlines SELECT
- `DashboardService.UpcomingDeadline` struct: Add `CaseID uuid.UUID` field
- Frontend `UpcomingDeadline` type: Already has `case_id` (it's in types.ts but the backend doesn't send it)
### 1.4 Quick Actions → Proper Navigation
**Current:** "Frist eintragen" goes to `/fristen` (list page), not a creation flow. "CalDAV Sync" goes to `/einstellungen`.
**Proposed:**
| Action | Current Target | New Target |
|--------|---------------|------------|
| Neue Akte | `/cases/new` | `/cases/new` (keep) |
| Frist eintragen | `/fristen` | `/fristen/neu` (new creation page) |
| Neuer Termin | (missing) | `/termine/neu` (new creation page) |
| AI Analyse | `/ai/extract` | `/ai/extract` (keep) |
Replace "CalDAV Sync" with "Neuer Termin" — CalDAV sync is a settings function, not a daily quick action. Creating an appointment is something a secretary does multiple times per day.
**Changes:**
- `QuickActions.tsx`: Update hrefs, swap CalDAV for appointment creation
- Create `/fristen/neu/page.tsx` — standalone deadline creation form (select case, fill fields)
- Create `/termine/neu/page.tsx` — standalone appointment creation form
### 1.5 AI Summary Card → Refresh Button
**Current:** Rule-based summary text, no refresh mechanism. Card regenerates on page load but not on demand.
**Proposed:**
- Add a small refresh icon button (RefreshCw) in the card header, next to "KI-Zusammenfassung"
- Clicking it calls `refetch()` on the dashboard query (passed as prop)
- Show a brief spinning animation during refetch
- If/when real AI summarization is wired up, this button triggers `POST /api/ai/summarize-dashboard` (future endpoint)
**Changes:**
- `AISummaryCard.tsx`: Accept `onRefresh` prop, add button with spinning state
- `dashboard/page.tsx`: Pass `refetch` to AISummaryCard
### 1.6 Dashboard Layout: Add Recent Activity Section
**Current:** The backend returns `recent_activity` (last 10 case events) but the frontend ignores it entirely.
**Proposed:**
- Add a "Letzte Aktivitaet" section below the timeline, full width
- Shows the 10 most recent case events in a compact list
- Each row: event icon (by type) | title | case number (linked) | relative time
- Clicking a row navigates to the case event detail page `/cases/{case_id}/ereignisse/{event_id}`
**Changes:**
- New component: `RecentActivityList.tsx` in `components/dashboard/`
- `dashboard/page.tsx`: Add section below the main grid
- Add `RecentActivity` type to `types.ts` (needs `case_id` and `event_id` fields from backend)
- Backend: Add `case_id` and `id` to the recent activity query
---
## Part 2: New Pages
### 2.1 Deadline Detail Page — `/fristen/{id}`
**Route:** `src/app/(app)/fristen/[id]/page.tsx`
**Layout:**
```
Breadcrumb: Dashboard > Fristen > {deadline.title}
+---------------------------------------------------------+
| [Status Badge] {deadline.title} [Erledigen] |
| Fällig: 28. März 2026 |
+---------------------------------------------------------+
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
| Quelle: Berechnet (R.118 RoP) |
| Ursprüngliches Datum: 25. März 2026 |
| Warnungsdatum: 21. März 2026 |
+---------------------------------------------------------+
| Notizen [Bearbeiten]|
| Fristverlängerung beantragt am 20.03. |
+---------------------------------------------------------+
| Verlauf |
| ○ Erstellt am 15.03.2026 |
| ○ Warnung gesendet am 21.03.2026 |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/deadlines/{id}` — new endpoint returning full deadline with case info
- Returns: Deadline + associated case (number, title, id) + notes
**Sections:**
1. **Header**: Status badge (Offen/Erledigt/Ueberfaellig), title, "Erledigen" action button
2. **Due date**: Large, with relative time ("in 3 Tagen" / "vor 2 Tagen ueberfaellig")
3. **Context panel**: Parent case (linked), source (manual/calculated/caldav), rule reference, original vs adjusted date
4. **Notes section**: Free-text notes (existing `notes` field on deadline), inline edit
5. **Activity log**: Timeline of changes to this deadline (future: from case_events filtered by deadline)
**Backend additions:**
- `GET /api/deadlines/{id}` — new handler returning single deadline with case join
- Handler: `deadlines.go` add `Get` method
- Service: `deadline_service.go` add `GetByID` with case join
### 2.2 Appointment Detail Page — `/termine/{id}`
**Route:** `src/app/(app)/termine/[id]/page.tsx`
**Layout:**
```
Breadcrumb: Dashboard > Termine > {appointment.title}
+---------------------------------------------------------+
| {appointment.title} [Bearbeiten] [X] |
| Typ: Verhandlung |
+---------------------------------------------------------+
| Datum: 28. März 2026, 10:00 12:00 Uhr |
| Ort: UPC München, Saal 3 |
+---------------------------------------------------------+
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
+---------------------------------------------------------+
| Beschreibung |
| Erste mündliche Verhandlung... |
+---------------------------------------------------------+
| Notizen [+ Neu] |
| ○ 25.03. — Mandant über Termin informiert |
| ○ 24.03. — Schriftsatz vorbereitet |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/appointments/{id}` — new endpoint returning single appointment with case info
- Notes: Uses new `notes` table (see Part 3)
**Backend additions:**
- `GET /api/appointments/{id}` — new handler
- Handler: `appointments.go` add `Get` method
- Service: `appointment_service.go` add `GetByID` with optional case join
### 2.3 Case Event Detail Page — `/cases/{id}/ereignisse/{eventId}`
**Route:** `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx`
**Layout:**
```
Breadcrumb: Akten > Az. 2024/001 > Verlauf > {event.title}
+---------------------------------------------------------+
| [Event Type Icon] {event.title} |
| 25. März 2026, 14:30 |
+---------------------------------------------------------+
| Beschreibung |
| Statusänderung: aktiv → geschlossen |
+---------------------------------------------------------+
| Metadaten |
| Erstellt von: max.mustermann@kanzlei.de |
| Typ: status_changed |
+---------------------------------------------------------+
| Notizen [+ Neu] |
| (keine Notizen) |
+---------------------------------------------------------+
```
**Data requirements:**
- `GET /api/case-events/{id}` — new endpoint
- Notes: Uses new `notes` table
**Backend additions:**
- New handler: `case_events.go` with `Get` method
- New service method: `CaseEventService.GetByID`
- Or extend existing case handler to include event fetching
### 2.4 Standalone Deadline Creation — `/fristen/neu`
**Route:** `src/app/(app)/fristen/neu/page.tsx`
**Layout:**
```
Breadcrumb: Fristen > Neue Frist
+---------------------------------------------------------+
| Neue Frist anlegen |
+---------------------------------------------------------+
| Akte*: [Dropdown: Aktenauswahl] |
| Bezeichnung*: [________________________] |
| Beschreibung: [________________________] |
| Fällig am*: [Datumsauswahl] |
| Warnung am: [Datumsauswahl] |
| Notizen: [Textarea] |
+---------------------------------------------------------+
| [Abbrechen] [Frist anlegen]|
+---------------------------------------------------------+
```
Reuses existing deadline creation logic but as a standalone page rather than requiring the user to first navigate to a case. Case is selected via dropdown.
### 2.5 Standalone Appointment Creation — `/termine/neu`
**Route:** `src/app/(app)/termine/neu/page.tsx`
Same pattern as deadline creation. Reuses AppointmentModal fields but as a full page form. Appointment can optionally be linked to a case.
### 2.6 Case Detail Tabs → URL Segments
**Current:** Tabs use `useState<TabKey>` — no URL change, no deep linking, no browser back.
**Proposed route structure:**
```
/cases/{id} → redirects to /cases/{id}/verlauf
/cases/{id}/verlauf → Timeline tab
/cases/{id}/fristen → Deadlines tab
/cases/{id}/dokumente → Documents tab
/cases/{id}/parteien → Parties tab
/cases/{id}/notizen → Notes tab (new)
```
**Implementation approach:**
Use Next.js nested layouts with a shared layout for the case header + tab bar:
```
src/app/(app)/cases/[id]/
layout.tsx # Case header + tab navigation
page.tsx # Redirect to ./verlauf
verlauf/page.tsx # Timeline
fristen/page.tsx # Deadlines
dokumente/page.tsx # Documents
parteien/page.tsx # Parties
notizen/page.tsx # Notes (new)
```
The `layout.tsx` fetches case data and renders the header + tab bar. Each child page renders its tab content. The active tab is determined by the current pathname.
**Benefits:**
- Deep linking: `/cases/abc123/fristen` opens directly to the deadlines tab
- Browser back button works between tabs
- Each tab can have its own loading state
- Bookmarkable
---
## Part 3: Notes System
### 3.1 Data Model
Notes are a polymorphic entity — they can be attached to cases, deadlines, appointments, or case events.
**New table: `kanzlai.notes`**
```sql
CREATE TABLE kanzlai.notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES kanzlai.tenants(id),
-- Polymorphic parent reference (exactly one must be set)
case_id UUID REFERENCES kanzlai.cases(id) ON DELETE CASCADE,
deadline_id UUID REFERENCES kanzlai.deadlines(id) ON DELETE CASCADE,
appointment_id UUID REFERENCES kanzlai.appointments(id) ON DELETE CASCADE,
case_event_id UUID REFERENCES kanzlai.case_events(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_by UUID, -- auth.users reference
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Ensure exactly one parent is set
CONSTRAINT notes_single_parent CHECK (
(CASE WHEN case_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN deadline_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN appointment_id IS NOT NULL THEN 1 ELSE 0 END +
CASE WHEN case_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1
)
);
-- Indexes for efficient lookup by parent
CREATE INDEX idx_notes_case ON kanzlai.notes(tenant_id, case_id) WHERE case_id IS NOT NULL;
CREATE INDEX idx_notes_deadline ON kanzlai.notes(tenant_id, deadline_id) WHERE deadline_id IS NOT NULL;
CREATE INDEX idx_notes_appointment ON kanzlai.notes(tenant_id, appointment_id) WHERE appointment_id IS NOT NULL;
CREATE INDEX idx_notes_case_event ON kanzlai.notes(tenant_id, case_event_id) WHERE case_event_id IS NOT NULL;
-- RLS
ALTER TABLE kanzlai.notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY notes_tenant_isolation ON kanzlai.notes
USING (tenant_id IN (
SELECT tenant_id FROM kanzlai.user_tenants WHERE user_id = auth.uid()
));
```
### 3.2 Why Polymorphic Table vs Separate Tables
**Considered alternatives:**
1. **Separate notes per entity** (case_notes, deadline_notes, etc.) — More tables, duplicated logic, harder to search across all notes.
2. **Generic `entity_type` + `entity_id` pattern** — Loses FK constraints, can't cascade delete, harder to query with joins.
3. **Polymorphic with nullable FKs** (chosen) — FK constraints maintained, cascade deletes work, partial indexes keep queries fast, single service/handler. The CHECK constraint ensures data integrity.
### 3.3 Backend Model & API
**Go model:**
```go
type Note struct {
ID uuid.UUID `db:"id" json:"id"`
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"`
AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"`
CaseEventID *uuid.UUID `db:"case_event_id" json:"case_event_id,omitempty"`
Content string `db:"content" json:"content"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
```
**API endpoints:**
```
GET /api/notes?case_id={id} # List notes for a case
GET /api/notes?deadline_id={id} # List notes for a deadline
GET /api/notes?appointment_id={id} # List notes for an appointment
GET /api/notes?case_event_id={id} # List notes for a case event
POST /api/notes # Create note (body includes parent ID)
PUT /api/notes/{id} # Update note content
DELETE /api/notes/{id} # Delete note
```
Single endpoint with query parameter filtering — simpler than nested routes, works uniformly across all parent types.
**Service methods:**
```go
type NoteService struct { db *sqlx.DB }
func (s *NoteService) ListByParent(ctx, tenantID, parentType, parentID) ([]Note, error)
func (s *NoteService) Create(ctx, tenantID, note) (*Note, error)
func (s *NoteService) Update(ctx, tenantID, noteID, content) (*Note, error)
func (s *NoteService) Delete(ctx, tenantID, noteID) error
```
### 3.4 Notes UI Component
Reusable `<NotesList>` component used on every detail page:
```
+------------------------------------------------------------+
| Notizen [+ Neu] |
+------------------------------------------------------------+
| m@kanzlei.de · 25. Mär 2026, 14:30 [X][E] |
| Fristverlängerung beim Gericht beantragt. |
+------------------------------------------------------------+
| m@kanzlei.de · 24. Mär 2026, 10:15 [X][E] |
| Mandant telefonisch über Sachstand informiert. |
+------------------------------------------------------------+
```
**Props:**
```typescript
interface NotesListProps {
parentType: "case" | "deadline" | "appointment" | "case_event";
parentId: string;
}
```
**Features:**
- Fetches notes via `GET /api/notes?{parentType}_id={parentId}`
- "Neu" button opens inline textarea (not a modal — faster for quick notes)
- Each note shows: author, timestamp, content, edit/delete buttons
- Edit is inline (textarea replaces content)
- Optimistic updates via react-query mutation + invalidation
- Empty state: "Keine Notizen vorhanden. Klicken Sie +, um eine Notiz hinzuzufügen."
### 3.5 Migration from `deadlines.notes` Field
The existing `deadlines.notes` text field should be migrated:
1. For each deadline with a non-null `notes` value, create a corresponding row in the `notes` table with `deadline_id` set
2. Drop the `deadlines.notes` column after migration
3. This can be a one-time SQL migration script
---
## Part 4: Breadcrumb Navigation
### 4.1 Breadcrumb Component
New shared component: `src/components/layout/Breadcrumb.tsx`
```typescript
interface BreadcrumbItem {
label: string;
href?: string; // omit for current page (last item)
}
function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
// Renders: Home > Parent > Current
// Each item with href is a Link, last item is plain text
}
```
**Placement:** At the top of every page, inside the main content area (not in the layout — different pages have different breadcrumbs).
### 4.2 Breadcrumb Patterns
| Page | Breadcrumb |
|------|-----------|
| Dashboard | Dashboard |
| Fristen | Dashboard > Fristen |
| Fristen Detail | Dashboard > Fristen > {title} |
| Fristen Neu | Dashboard > Fristen > Neue Frist |
| Termine | Dashboard > Termine |
| Termine Detail | Dashboard > Termine > {title} |
| Termine Neu | Dashboard > Termine > Neuer Termin |
| Akten | Dashboard > Akten |
| Akte Detail | Dashboard > Akten > {case_number} |
| Akte > Fristen | Dashboard > Akten > {case_number} > Fristen |
| Akte > Notizen | Dashboard > Akten > {case_number} > Notizen |
| Ereignis Detail | Dashboard > Akten > {case_number} > Verlauf > {title} |
| Einstellungen | Dashboard > Einstellungen |
| AI Analyse | Dashboard > AI Analyse |
---
## Part 5: Summary of Backend Changes
### New Endpoints
| Method | Path | Handler | Purpose |
|--------|------|---------|---------|
| GET | `/api/deadlines/{id}` | `deadlineH.Get` | Single deadline with case context |
| GET | `/api/appointments/{id}` | `apptH.Get` | Single appointment with case context |
| GET | `/api/case-events/{id}` | `eventH.Get` | Single case event |
| GET | `/api/notes` | `noteH.List` | List notes (filtered by parent) |
| POST | `/api/notes` | `noteH.Create` | Create note |
| PUT | `/api/notes/{id}` | `noteH.Update` | Update note |
| DELETE | `/api/notes/{id}` | `noteH.Delete` | Delete note |
### Modified Endpoints
| Endpoint | Change |
|----------|--------|
| `GET /api/dashboard` | Add `case_id`, `id` to recent_activity; add `case_id` to upcoming_deadlines query |
### New Files
| File | Purpose |
|------|---------|
| `backend/internal/models/note.go` | Note model |
| `backend/internal/services/note_service.go` | Note CRUD service |
| `backend/internal/handlers/notes.go` | Note HTTP handlers |
| `backend/internal/handlers/case_events.go` | Case event detail handler |
### Database Migration
1. Create `kanzlai.notes` table with polymorphic FK pattern
2. Migrate existing `deadlines.notes` data
3. Drop `deadlines.notes` column
---
## Part 6: Summary of Frontend Changes
### New Files
| File | Purpose |
|------|---------|
| `src/app/(app)/fristen/[id]/page.tsx` | Deadline detail page |
| `src/app/(app)/fristen/neu/page.tsx` | Standalone deadline creation |
| `src/app/(app)/termine/[id]/page.tsx` | Appointment detail page |
| `src/app/(app)/termine/neu/page.tsx` | Standalone appointment creation |
| `src/app/(app)/cases/[id]/layout.tsx` | Case detail shared layout (header + tabs) |
| `src/app/(app)/cases/[id]/verlauf/page.tsx` | Case timeline tab |
| `src/app/(app)/cases/[id]/fristen/page.tsx` | Case deadlines tab |
| `src/app/(app)/cases/[id]/dokumente/page.tsx` | Case documents tab |
| `src/app/(app)/cases/[id]/parteien/page.tsx` | Case parties tab |
| `src/app/(app)/cases/[id]/notizen/page.tsx` | Case notes tab (new) |
| `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx` | Case event detail |
| `src/components/layout/Breadcrumb.tsx` | Reusable breadcrumb |
| `src/components/notes/NotesList.tsx` | Reusable notes list + inline creation |
| `src/components/dashboard/RecentActivityList.tsx` | Recent activity feed |
### Modified Files
| File | Change |
|------|--------|
| `src/components/dashboard/DeadlineTrafficLights.tsx` | Buttons → Links with navigation |
| `src/components/dashboard/CaseOverviewGrid.tsx` | Static metrics → clickable links |
| `src/components/dashboard/UpcomingTimeline.tsx` | Items → clickable with navigation |
| `src/components/dashboard/AISummaryCard.tsx` | Add refresh button |
| `src/components/dashboard/QuickActions.tsx` | Fix targets, swap CalDAV for Termin |
| `src/app/(app)/dashboard/page.tsx` | Wire navigation, add RecentActivity section |
| `src/app/(app)/fristen/page.tsx` | Read URL params for initial filter |
| `src/app/(app)/cases/page.tsx` | Read URL params for initial filter |
| `src/app/(app)/cases/[id]/page.tsx` | Refactor into layout + nested routes |
| `src/lib/types.ts` | Add Note, RecentActivity types; update UpcomingDeadline |
### Types to Add
```typescript
export interface Note {
id: string;
tenant_id: string;
case_id?: string;
deadline_id?: string;
appointment_id?: string;
case_event_id?: string;
content: string;
created_by?: string;
created_at: string;
updated_at: string;
}
export interface RecentActivity {
id: string;
event_type?: string;
title: string;
case_id: string;
case_number: string;
event_date?: string;
created_at: string;
}
```
---
## Part 7: Implementation Plan
Recommended order for a coder to implement:
### Phase A: Backend Foundation (can be done in parallel)
1. Create `notes` table migration + model + service + handler
2. Add `GET /api/deadlines/{id}` endpoint
3. Add `GET /api/appointments/{id}` endpoint
4. Add `GET /api/case-events/{id}` endpoint
5. Fix dashboard query to include `case_id` in upcoming deadlines and recent activity
### Phase B: Frontend — Dashboard Interactivity
1. Create `Breadcrumb` component
2. Make traffic light cards clickable (Links)
3. Make case overview grid clickable (Links)
4. Make timeline items clickable (Links)
5. Fix quick actions (swap CalDAV for Termin, update hrefs)
6. Add refresh button to AI Summary card
7. Add RecentActivityList component + wire to dashboard
### Phase C: Frontend — New Detail Pages
1. Deadline detail page (`/fristen/[id]`)
2. Appointment detail page (`/termine/[id]`)
3. Case event detail page (`/cases/[id]/ereignisse/[eventId]`)
4. Standalone deadline creation (`/fristen/neu`)
5. Standalone appointment creation (`/termine/neu`)
### Phase D: Frontend — Case Detail Refactor
1. Extract case header + tabs into layout.tsx
2. Create sub-route pages (verlauf, fristen, dokumente, parteien)
3. Add notes tab
4. Wire `NotesList` component into all detail pages
### Phase E: Polish
1. URL filter params on `/fristen` and `/cases` pages
2. Breadcrumbs on all pages
3. Mobile responsive testing
4. Migration of existing `deadlines.notes` data
---
## Appendix: What This Design Does NOT Cover
- Real AI-powered summary (currently rule-based — kept as-is with refresh button)
- Notification system (toast-based alerts for approaching deadlines)
- Audit log / change history per entity
- Batch operations (mark multiple deadlines complete)
- Print views
These are separate features that can be designed independently.

0
frontend/.m/spawn.lock Normal file
View File

View File

@@ -27,7 +27,7 @@ export default function AIExtractPage() {
queryFn: () => api.get<PaginatedResponse<Case>>("/cases"), queryFn: () => api.get<PaginatedResponse<Case>>("/cases"),
}); });
const cases = casesData?.data ?? []; const cases = Array.isArray(casesData?.data) ? casesData.data : [];
async function handleExtract(file: File | null, text: string) { async function handleExtract(file: File | null, text: string) {
setIsExtracting(true); setIsExtracting(true);

View File

@@ -132,8 +132,8 @@ export default function CaseDetailPage() {
); );
} }
const deadlines = deadlinesData?.deadlines ?? []; const deadlines = Array.isArray(deadlinesData?.deadlines) ? deadlinesData.deadlines : [];
const documents = documentsData ?? []; const documents = Array.isArray(documentsData) ? documentsData : [];
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
@@ -205,7 +205,7 @@ export default function CaseDetailPage() {
{caseDetail.deadlines_count} {caseDetail.deadlines_count}
</span> </span>
)} )}
{tab.key === "parties" && caseDetail.parties.length > 0 && ( {tab.key === "parties" && Array.isArray(caseDetail.parties) && caseDetail.parties.length > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500"> <span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{caseDetail.parties.length} {caseDetail.parties.length}
</span> </span>
@@ -217,7 +217,7 @@ export default function CaseDetailPage() {
<div className="mt-6"> <div className="mt-6">
{activeTab === "timeline" && ( {activeTab === "timeline" && (
<CaseTimeline events={caseDetail.recent_events ?? []} /> <CaseTimeline events={Array.isArray(caseDetail.recent_events) ? caseDetail.recent_events : []} />
)} )}
{activeTab === "deadlines" && ( {activeTab === "deadlines" && (
@@ -229,7 +229,7 @@ export default function CaseDetailPage() {
)} )}
{activeTab === "parties" && ( {activeTab === "parties" && (
<PartyList caseId={id} parties={caseDetail.parties ?? []} /> <PartyList caseId={id} parties={Array.isArray(caseDetail.parties) ? caseDetail.parties : []} />
)} )}
</div> </div>
</div> </div>

View File

@@ -68,7 +68,7 @@ export default function CasesPage() {
}, },
}); });
const cases = data?.cases ?? []; const cases = Array.isArray(data?.cases) ? data.cases : [];
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">

View File

@@ -80,17 +80,17 @@ export default function DashboardPage() {
</p> </p>
</div> </div>
<DeadlineTrafficLights data={data.deadline_summary} /> <DeadlineTrafficLights data={data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 }} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<UpcomingTimeline <UpcomingTimeline
deadlines={data.upcoming_deadlines} deadlines={Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : []}
appointments={data.upcoming_appointments} appointments={Array.isArray(data.upcoming_appointments) ? data.upcoming_appointments : []}
/> />
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<CaseOverviewGrid data={data.case_summary} /> <CaseOverviewGrid data={data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 }} />
<AISummaryCard data={data} /> <AISummaryCard data={data} />
<QuickActions /> <QuickActions />
</div> </div>

View File

@@ -22,7 +22,7 @@ export default function EinstellungenPage() {
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["tenant-current", tenantId], queryKey: ["tenant-current", tenantId],
queryFn: () => api.get<Tenant>(`/api/tenants/${tenantId}`), queryFn: () => api.get<Tenant>(`/tenants/${tenantId}`),
enabled: !!tenantId, enabled: !!tenantId,
}); });

View File

@@ -66,7 +66,7 @@ export default function FristenPage() {
{view === "list" ? ( {view === "list" ? (
<DeadlineList /> <DeadlineList />
) : ( ) : (
<DeadlineCalendarView deadlines={deadlines || []} /> <DeadlineCalendarView deadlines={Array.isArray(deadlines) ? deadlines : []} />
)} )}
</div> </div>
); );

View File

@@ -84,7 +84,7 @@ export default function TerminePage() {
<AppointmentList onEdit={handleEdit} /> <AppointmentList onEdit={handleEdit} />
) : ( ) : (
<AppointmentCalendar <AppointmentCalendar
appointments={appointments || []} appointments={Array.isArray(appointments) ? appointments : []}
onAppointmentClick={handleEdit} onAppointmentClick={handleEdit}
/> />
)} )}

View File

@@ -73,12 +73,13 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
const caseMap = useMemo(() => { const caseMap = useMemo(() => {
const map = new Map<string, Case>(); const map = new Map<string, Case>();
cases?.cases?.forEach((c) => map.set(c.id, c)); const arr = Array.isArray(cases?.cases) ? cases.cases : [];
arr.forEach((c) => map.set(c.id, c));
return map; return map;
}, [cases]); }, [cases]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!appointments) return []; if (!Array.isArray(appointments)) return [];
return appointments return appointments
.filter((a) => { .filter((a) => {
if (caseFilter !== "all" && a.case_id !== caseFilter) return false; if (caseFilter !== "all" && a.case_id !== caseFilter) return false;
@@ -91,7 +92,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
const grouped = useMemo(() => groupByDate(filtered), [filtered]); const grouped = useMemo(() => groupByDate(filtered), [filtered]);
const counts = useMemo(() => { const counts = useMemo(() => {
if (!appointments) return { today: 0, thisWeek: 0, total: 0 }; if (!Array.isArray(appointments)) return { today: 0, thisWeek: 0, total: 0 };
let today = 0; let today = 0;
let thisWeek = 0; let thisWeek = 0;
for (const a of appointments) { for (const a of appointments) {
@@ -148,7 +149,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
</option> </option>
))} ))}
</select> </select>
{cases?.cases && cases.cases.length > 0 && ( {Array.isArray(cases?.cases) && cases.cases.length > 0 && (
<select <select
value={caseFilter} value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)} onChange={(e) => setCaseFilter(e.target.value)}

View File

@@ -78,7 +78,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) => mutationFn: (body: Record<string, unknown>) =>
api.put<Appointment>(`/api/appointments/${appointment!.id}`, body), api.put<Appointment>(`/appointments/${appointment!.id}`, body),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] }); queryClient.invalidateQueries({ queryKey: ["appointments"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] });

View File

@@ -9,7 +9,9 @@ interface Props {
function generateSummary(data: DashboardData): string { function generateSummary(data: DashboardData): string {
const parts: string[] = []; const parts: string[] = [];
const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data; const ds = data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
const cs = data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
const ud = Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : [];
// Deadline urgency // Deadline urgency
if (ds.overdue_count > 0) { if (ds.overdue_count > 0) {

View File

@@ -8,24 +8,25 @@ interface Props {
} }
export function CaseOverviewGrid({ data }: Props) { export function CaseOverviewGrid({ data }: Props) {
const safe = data ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
const items = [ const items = [
{ {
label: "Aktive Akten", label: "Aktive Akten",
value: data.active_count, value: safe.active_count ?? 0,
icon: FolderOpen, icon: FolderOpen,
color: "text-blue-600", color: "text-blue-600",
bg: "bg-blue-50", bg: "bg-blue-50",
}, },
{ {
label: "Neu (Monat)", label: "Neu (Monat)",
value: data.new_this_month, value: safe.new_this_month ?? 0,
icon: FolderPlus, icon: FolderPlus,
color: "text-violet-600", color: "text-violet-600",
bg: "bg-violet-50", bg: "bg-violet-50",
}, },
{ {
label: "Abgeschlossen", label: "Abgeschlossen",
value: data.closed_count, value: safe.closed_count ?? 0,
icon: Archive, icon: Archive,
color: "text-neutral-500", color: "text-neutral-500",
bg: "bg-neutral-50", bg: "bg-neutral-50",

View File

@@ -31,24 +31,25 @@ interface Props {
} }
export function DeadlineTrafficLights({ data, onFilter }: Props) { export function DeadlineTrafficLights({ data, onFilter }: Props) {
const safe = data ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
const cards = [ const cards = [
{ {
key: "overdue" as const, key: "overdue" as const,
label: "Überfällig", label: "Überfällig",
count: data.overdue_count, count: safe.overdue_count ?? 0,
icon: AlertTriangle, icon: AlertTriangle,
bg: "bg-red-50", bg: "bg-red-50",
border: "border-red-200", border: "border-red-200",
iconColor: "text-red-500", iconColor: "text-red-500",
countColor: "text-red-700", countColor: "text-red-700",
labelColor: "text-red-600", labelColor: "text-red-600",
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "", ring: (safe.overdue_count ?? 0) > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
pulse: data.overdue_count > 0, pulse: (safe.overdue_count ?? 0) > 0,
}, },
{ {
key: "this_week" as const, key: "this_week" as const,
label: "Diese Woche", label: "Diese Woche",
count: data.due_this_week, count: safe.due_this_week ?? 0,
icon: Clock, icon: Clock,
bg: "bg-amber-50", bg: "bg-amber-50",
border: "border-amber-200", border: "border-amber-200",
@@ -61,7 +62,7 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
{ {
key: "ok" as const, key: "ok" as const,
label: "Im Zeitplan", label: "Im Zeitplan",
count: data.ok_count + data.due_next_week, count: (safe.ok_count ?? 0) + (safe.due_next_week ?? 0),
icon: CheckCircle, icon: CheckCircle,
bg: "bg-emerald-50", bg: "bg-emerald-50",
border: "border-emerald-200", border: "border-emerald-200",

View File

@@ -21,13 +21,16 @@ function formatDayLabel(date: Date): string {
} }
export function UpcomingTimeline({ deadlines, appointments }: Props) { export function UpcomingTimeline({ deadlines, appointments }: Props) {
const safeDeadlines = Array.isArray(deadlines) ? deadlines : [];
const safeAppointments = Array.isArray(appointments) ? appointments : [];
const items: TimelineItem[] = [ const items: TimelineItem[] = [
...deadlines.map((d) => ({ ...safeDeadlines.map((d) => ({
type: "deadline" as const, type: "deadline" as const,
date: parseISO(d.due_date), date: parseISO(d.due_date),
data: d, data: d,
})), })),
...appointments.map((a) => ({ ...safeAppointments.map((a) => ({
type: "appointment" as const, type: "appointment" as const,
date: parseISO(a.start_at), date: parseISO(a.start_at),
data: a, data: a,

View File

@@ -64,7 +64,7 @@ export function DeadlineList() {
const completeMutation = useMutation({ const completeMutation = useMutation({
mutationFn: (id: string) => mutationFn: (id: string) =>
api.patch<Deadline>(`/api/deadlines/${id}/complete`), api.patch<Deadline>(`/deadlines/${id}/complete`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deadlines"] }); queryClient.invalidateQueries({ queryKey: ["deadlines"] });
toast.success("Frist als erledigt markiert"); toast.success("Frist als erledigt markiert");
@@ -76,12 +76,12 @@ export function DeadlineList() {
const caseMap = useMemo(() => { const caseMap = useMemo(() => {
const map = new Map<string, Case>(); const map = new Map<string, Case>();
cases?.forEach((c) => map.set(c.id, c)); (Array.isArray(cases) ? cases : []).forEach((c) => map.set(c.id, c));
return map; return map;
}, [cases]); }, [cases]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!deadlines) return []; if (!Array.isArray(deadlines)) return [];
return deadlines.filter((d) => { return deadlines.filter((d) => {
if (statusFilter === "pending" && d.status !== "pending") return false; if (statusFilter === "pending" && d.status !== "pending") return false;
if (statusFilter === "completed" && d.status !== "completed") if (statusFilter === "completed" && d.status !== "completed")
@@ -96,7 +96,7 @@ export function DeadlineList() {
}, [deadlines, statusFilter, caseFilter]); }, [deadlines, statusFilter, caseFilter]);
const counts = useMemo(() => { const counts = useMemo(() => {
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 }; if (!Array.isArray(deadlines)) return { overdue: 0, thisWeek: 0, ok: 0 };
let overdue = 0, let overdue = 0,
thisWeek = 0, thisWeek = 0,
ok = 0; ok = 0;
@@ -188,7 +188,7 @@ export function DeadlineList() {
<option value="completed">Erledigt</option> <option value="completed">Erledigt</option>
<option value="overdue">Überfällig</option> <option value="overdue">Überfällig</option>
</select> </select>
{cases && cases.length > 0 && ( {Array.isArray(cases) && cases.length > 0 && (
<select <select
value={caseFilter} value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)} onChange={(e) => setCaseFilter(e.target.value)}

View File

@@ -15,6 +15,7 @@ export function TenantSwitcher() {
api api
.get<TenantWithRole[]>("/tenants") .get<TenantWithRole[]>("/tenants")
.then((data) => { .then((data) => {
if (!Array.isArray(data)) return;
setTenants(data); setTenants(data);
const savedId = localStorage.getItem("kanzlai_tenant_id"); const savedId = localStorage.getItem("kanzlai_tenant_id");
const match = data.find((t) => t.id === savedId) || data[0]; const match = data.find((t) => t.id === savedId) || data[0];
@@ -53,30 +54,33 @@ export function TenantSwitcher() {
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50" className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
> >
<span className="max-w-[120px] truncate sm:max-w-[160px]"> <span className="max-w-[200px] truncate sm:max-w-[280px]">
{current.name} {current.name}
</span> </span>
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" /> <ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
</button> </button>
{open && tenants.length > 1 && ( {open && (
<div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg"> <div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-64 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
{tenants.map((tenant) => ( {tenants.map((tenant) => (
<button <button
key={tenant.id} key={tenant.id}
onClick={() => switchTenant(tenant)} onClick={() => switchTenant(tenant)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${ className={`flex w-full items-center px-3 py-2 text-left text-sm transition-colors ${
tenant.id === current.id tenant.id === current.id
? "bg-neutral-50 font-medium text-neutral-900" ? "bg-neutral-50 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50" : "text-neutral-600 hover:bg-neutral-50"
}`} }`}
> >
<span className="truncate">{tenant.name}</span> <span className="truncate">{tenant.name}</span>
<span className="ml-auto text-xs text-neutral-400"> <span className="ml-auto shrink-0 text-xs text-neutral-400">
{tenant.role} {tenant.role}
</span> </span>
</button> </button>
))} ))}
{tenants.length <= 1 && (
<p className="px-3 py-2 text-xs text-neutral-400">Einzige Kanzlei</p>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -68,7 +68,7 @@ export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
typeof window !== "undefined" typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id") ? localStorage.getItem("kanzlai_tenant_id")
: null; : null;
return api.put<Tenant>(`/api/tenants/${tenantId}/settings`, { return api.put<Tenant>(`/tenants/${tenantId}/settings`, {
caldav: cfg, caldav: cfg,
}); });
}, },

View File

@@ -32,13 +32,13 @@ export function TeamSettings() {
} = useQuery({ } = useQuery({
queryKey: ["tenant-members", tenantId], queryKey: ["tenant-members", tenantId],
queryFn: () => queryFn: () =>
api.get<UserTenant[]>(`/api/tenants/${tenantId}/members`), api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
enabled: !!tenantId, enabled: !!tenantId,
}); });
const inviteMutation = useMutation({ const inviteMutation = useMutation({
mutationFn: (data: { email: string; role: string }) => mutationFn: (data: { email: string; role: string }) =>
api.post(`/api/tenants/${tenantId}/invite`, data), api.post(`/tenants/${tenantId}/invite`, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
setEmail(""); setEmail("");
@@ -52,7 +52,7 @@ export function TeamSettings() {
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (userId: string) => mutationFn: (userId: string) =>
api.delete(`/api/tenants/${tenantId}/members/${userId}`), api.delete(`/tenants/${tenantId}/members/${userId}`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] }); queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
toast.success("Mitglied entfernt"); toast.success("Mitglied entfernt");
@@ -118,7 +118,7 @@ export function TeamSettings() {
</form> </form>
{/* Members List */} {/* Members List */}
{members && members.length > 0 ? ( {Array.isArray(members) && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200"> <div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => { {members.map((member, i) => {
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member; const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;