Compare commits

...

32 Commits

Author SHA1 Message Date
m
fe97fed56d feat: add CalDAV settings UI and team management pages (Phase 3P)
Backend: PUT /api/tenants/{id}/settings endpoint for updating tenant
settings (JSONB merge). Frontend: /einstellungen page with CalDAV
config (URL, credentials, calendar path, sync toggle, interval),
manual sync button, live sync status display. /einstellungen/team
page with member list, invite-by-email, role management.
2026-03-25 14:26:05 +01:00
m
b49992b9c0 feat: UI polish — responsive, loading/empty/error states, German (Phase 3Q) 2026-03-25 14:20:08 +01:00
m
f81a2492c6 feat: UI polish — responsive, loading/empty/error states, German fixes (Phase 3Q)
- Responsive sidebar: collapses on mobile with hamburger menu, slide-in animation
- Skeleton loaders: dashboard cards, case table, case detail page
- Empty states: friendly messages with icons for cases, deadlines, parties, documents
- Error states: retry button on dashboard, proper error message on case not found
- Form validation: inline error messages on case creation form
- German language: fix all missing umlauts (Zurück, wählen, Anhängig, Verfügung, etc.)
- Status labels: display German translations instead of raw status values
- Transitions: fade-in animations on page load, hover/transition-colors on all interactive elements
- Focus states: focus-visible ring for keyboard accessibility
- Mobile layout: stacking for filters, forms, tabs; horizontal scroll for tables
- Extraction results: card layout on mobile, table on desktop
- Missing types: add DashboardData, DeadlineSummary, CaseSummary, ExtractedDeadline etc.
- Fix QuickActions links to use correct routes (/cases/new, /ai/extract)
- Consistent input focus styles across all forms
2026-03-25 14:16:30 +01:00
m
8bb8d7fed8 feat: add CalDAV bidirectional sync service (Phase 3O) 2026-03-25 14:04:38 +01:00
m
b4f3b26cbe feat: add document management frontend (Phase 2N) 2026-03-25 14:04:28 +01:00
m
6e9345fcfe feat: add appointment calendar frontend (Phase 1H) 2026-03-25 14:04:12 +01:00
m
785df2ced4 feat: add CalDAV bidirectional sync service (Phase 3O)
Implements CalDAV sync using github.com/emersion/go-webdav:

- CalDAVService with background polling (configurable per-tenant interval)
- Push: deadlines -> VTODO, appointments -> VEVENT on create/update/delete
- Pull: periodic fetch from CalDAV, reconcile with local DB
- Conflict resolution: KanzlAI wins dates/status, CalDAV wins notes/description
- Conflicts logged as case_events with caldav_conflict type
- UID pattern: kanzlai-{deadline|appointment}-{uuid}@kanzlai.msbls.de
- CalDAV config per tenant in tenants.settings JSONB

Endpoints:
- POST /api/caldav/sync — trigger full sync for current tenant
- GET /api/caldav/status — last sync time, item counts, errors

8 unit tests for UID generation, parsing, path construction, config parsing.
2026-03-25 14:01:30 +01:00
m
749273fba7 feat: add appointment calendar frontend (Phase 1H)
- /termine page with list/calendar view toggle
- AppointmentList: date-grouped list with type/case filtering, summary cards
- AppointmentCalendar: month grid with colored type dots, clickable days/appointments
- AppointmentModal: create/edit/delete with case linking, type selection, location
2026-03-25 14:00:56 +01:00
m
0ab2e8b383 feat: add document management frontend (Phase 2N)
- DocumentUpload: dropzone with multi-file support, upload via
  POST /api/cases/{id}/documents, progress feedback with toast
- DocumentList: type badges, file size, upload date, download links,
  delete with inline confirmation
- Integrated as Dokumente tab in case detail page with count badge
- Eagerly fetches document count for tab badge display
2026-03-25 13:59:48 +01:00
m
2cf01073a3 feat: add AI extraction frontend page (Phase 2M) 2026-03-25 13:54:49 +01:00
m
ed83d23d06 feat: add deadline management frontend (Phase 1G) 2026-03-25 13:54:35 +01:00
m
97ebeafcf7 feat: add case list, detail, and creation pages (Phase 1F) 2026-03-25 13:54:23 +01:00
m
26887248e1 feat: add dashboard with traffic lights, timeline, AI summary (Phase 2L) 2026-03-25 13:54:13 +01:00
m
1fa7d90050 feat: add deadline management frontend (Phase 1G)
- Fristen page with list view (sortable, filterable by status/case)
- Calendar view with month navigation and deadline dots
- Deadline calculator page (proceeding type + trigger date = timeline)
- Traffic light urgency: red (overdue), amber (this week), green (OK)
- Backend: GET /api/deadlines (all tenant deadlines), GET /api/proceeding-types
- API client: added patch() method
- Types: DeadlineRule, ProceedingType, CalculatedDeadline, RuleTreeNode
2026-03-25 13:53:12 +01:00
m
3a56d4cf11 feat: add frontend case list, detail, and creation pages (Phase 1F)
- Case list page (/cases) with search, status/type filters, status badges
- Case creation page (/cases/new) with reusable CaseForm component
- Case detail page (/cases/[id]) with tabs: Timeline, Deadlines, Documents, Parties
- CaseTimeline component for chronological case_events display
- PartyList component with inline party CRUD (add/delete)
- Updated sidebar navigation to route to /cases
2026-03-25 13:50:20 +01:00
m
45188ff5cb feat: add frontend dashboard with traffic lights, timeline, and AI summary (Phase 2L)
Dashboard page at /dashboard with 5 components:
- DeadlineTrafficLights: RED/AMBER/GREEN cards with animated counts and pulse for overdue
- CaseOverviewGrid: active/new/closed case counts
- UpcomingTimeline: merged deadlines + appointments for next 7 days, grouped by day
- AISummaryCard: natural language summary generated from dashboard data
- QuickActions: shortcuts to create cases, deadlines, AI analysis, CalDAV sync

3-column responsive grid layout. Root / redirects to /dashboard.
Fetches from GET /api/dashboard with 60s auto-refresh via react-query.
2026-03-25 13:49:24 +01:00
m
65b70975eb feat: add AI deadline extraction frontend page (Phase 2M)
- ExtractionForm: PDF dropzone (react-dropzone) + text textarea + case selector
- ExtractionResults: review table with edit/remove per row, confidence color-coding
- Page at /ai/extract: upload -> analyze -> review -> adopt deadlines to case
- Extended API client with postFormData for multipart uploads
- Added ExtractedDeadline and ExtractionResponse types
2026-03-25 13:48:53 +01:00
m
0fac764211 feat: add AI deadline extraction and case summary (Phase 2J) 2026-03-25 13:44:58 +01:00
m
78c511bd1f feat: add document upload/download backend (Phase 2K) 2026-03-25 13:44:01 +01:00
m
ca572d3289 feat: add dashboard aggregation endpoint (Phase 2I) 2026-03-25 13:43:29 +01:00
m
b2b3e04d05 feat: add frontend auth, app layout, and Supabase integration (Phase 1E) 2026-03-25 13:43:21 +01:00
m
5758e2c37f feat: add AI deadline extraction and case summary endpoints (Phase 2J)
Add two Claude API-powered endpoints:
- POST /api/ai/extract-deadlines: accepts PDF upload or JSON text, extracts
  legal deadlines using Claude tool_use for structured output
- POST /api/ai/summarize-case: generates AI summary from case events/deadlines,
  caches result in cases.ai_summary

New files:
- internal/services/ai_service.go: AIService with Anthropic SDK integration
- internal/handlers/ai.go: HTTP handlers for both endpoints
- internal/services/ai_service_test.go: tool schema and serialization tests

Uses anthropic-sdk-go v1.27.1 with Claude Sonnet 4.5. AI service is optional —
endpoints only registered when ANTHROPIC_API_KEY is set.
2026-03-25 13:40:27 +01:00
m
9bd8cc9e07 feat: add document upload/download backend (Phase 2K)
- StorageClient for Supabase Storage REST API (upload, download, delete)
- DocumentService with CRUD operations + storage integration
- DocumentHandler with multipart form upload support (50MB limit)
- Routes: GET/POST /api/cases/{id}/documents, GET/DELETE /api/documents/{docId}
- file_path format: {tenant_id}/{case_id}/{uuid}_{filename}
- Case events logged on upload/delete
- Added SUPABASE_SERVICE_KEY to config for server-side storage access
- Fixed pre-existing duplicate writeJSON/writeError in appointments.go
2026-03-25 13:40:19 +01:00
m
bf225284d8 feat: add frontend auth pages, app layout, and Supabase integration (Phase 1E)
- Auth pages: login (password + magic link), register (with firm name), callback
- Supabase client setup: browser client, server client, middleware for session refresh
- App layout: sidebar (Dashboard, Akten, Fristen, Termine, AI Analyse, Einstellungen),
  header with user info and tenant switcher
- Shared: API client with auth headers, TypeScript types matching Go models,
  QueryClientProvider + Toaster providers
- Dependencies: @supabase/supabase-js, @supabase/ssr, @tanstack/react-query,
  lucide-react, date-fns, sonner
2026-03-25 13:39:16 +01:00
m
e53e1389f9 feat: add dashboard aggregation endpoint (Phase 2I)
GET /api/dashboard returns aggregated data:
- deadline_summary: overdue, due this/next week, ok counts
- case_summary: active, new this month, closed counts
- upcoming_deadlines: next 7 days with case info
- upcoming_appointments: next 7 days
- recent_activity: last 10 case events

Uses efficient CTE query for summaries. Also fixes duplicate
writeJSON/writeError declarations in appointments handler.
2026-03-25 13:37:06 +01:00
m
2c16f26448 feat: add deadline CRUD, calculator, and holiday services (Phase 1C) 2026-03-25 13:33:57 +01:00
m
f0ee5921cf feat: add appointment CRUD backend (Phase 1D) 2026-03-25 13:32:51 +01:00
m
ba29fc75c7 feat: add case + party CRUD with case events (Phase 1B) 2026-03-25 13:32:15 +01:00
m
8350a7e7fb feat: add tenant + auth backend endpoints (Phase 1A) 2026-03-25 13:31:38 +01:00
m
42a62d45bf feat: add deadline CRUD, calculator, and holiday services (Phase 1C)
- Holiday service with German federal holidays, Easter calculation, DB loading
- Deadline calculator adapted from youpc.org (duration calc + non-working day adjustment)
- Deadline CRUD service (tenant-scoped: list, create, update, complete, delete)
- Deadline rule service (list, filter by proceeding type, hierarchical rule trees)
- HTTP handlers for all endpoints with tenant resolution via X-Tenant-ID header
- Router wired with all new endpoints under /api/
- Tests for holiday and calculator services (8 passing)
2026-03-25 13:31:29 +01:00
m
0b6bab8512 feat: add tenant + auth backend endpoints (Phase 1A)
Tenant management:
- POST /api/tenants — create tenant (creator becomes owner)
- GET /api/tenants — list tenants for authenticated user
- GET /api/tenants/:id — tenant details with access check
- POST /api/tenants/:id/invite — invite user by email (owner/admin)
- DELETE /api/tenants/:id/members/:uid — remove member
- GET /api/tenants/:id/members — list members

New packages:
- internal/services/tenant_service.go — CRUD on tenants + user_tenants
- internal/handlers/tenant_handler.go — HTTP handlers with auth checks
- internal/auth/tenant_resolver.go — X-Tenant-ID header middleware,
  defaults to user's first tenant for scoped routes

Authorization: owners/admins can invite and remove members. Cannot
remove the last owner. Users can remove themselves. TenantResolver
applies to resource routes (cases, deadlines, etc.) but not tenant
management routes.
2026-03-25 13:27:39 +01:00
m
bd15b4eb38 feat: add appointment CRUD backend (Phase 1D)
- AppointmentService with tenant-scoped List, GetByID, Create, Update, Delete
- List supports filtering by case_id, appointment_type, and date range (start_from/start_to)
- AppointmentHandler with JSON request/response handling and input validation
- Router wired up: GET/POST /api/appointments, PUT/DELETE /api/appointments/{id}
2026-03-25 13:25:46 +01:00
116 changed files with 10994 additions and 67 deletions

14
.claude/agents/coder.md Normal file
View File

@@ -0,0 +1,14 @@
# Coder Agent
Implementation-focused agent for writing and refactoring code.
## Instructions
- Follow existing patterns in the codebase
- Write minimal, focused code
- Run tests after changes
- Commit incrementally with descriptive messages
## Tools
All tools available.

View File

@@ -0,0 +1,14 @@
# Researcher Agent
Exploration and information gathering agent.
## Instructions
- Search broadly, then narrow down
- Document findings in structured format
- Cite sources and file paths
- Summarize key insights, don't dump raw data
## Tools
Read-only tools preferred. Use Bash only for non-destructive commands.

View File

@@ -0,0 +1,14 @@
# Reviewer Agent
Code review agent for checking quality and correctness.
## Instructions
- Check for bugs, security issues, and style violations
- Verify test coverage for changes
- Suggest improvements concisely
- Focus on correctness over style preferences
## Tools
Read-only tools. No file modifications.

1
.claude/skills/mai-clone Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-clone

1
.claude/skills/mai-coder Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-coder

1
.claude/skills/mai-commit Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-commit

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-consultant

1
.claude/skills/mai-daily Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-daily

1
.claude/skills/mai-debrief Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-debrief

1
.claude/skills/mai-enemy Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-enemy

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-excalidraw

1
.claude/skills/mai-fixer Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-fixer

1
.claude/skills/mai-gitster Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-gitster

1
.claude/skills/mai-head Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-head

1
.claude/skills/mai-init Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-init

1
.claude/skills/mai-inventor Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-inventor

1
.claude/skills/mai-lead Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-lead

1
.claude/skills/mai-maister Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-maister

1
.claude/skills/mai-member Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-member

View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-researcher

1
.claude/skills/mai-think Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-think

1
.claude/skills/mai-web Symbolic link
View File

@@ -0,0 +1 @@
/home/m/.mai/skills/mai-web

View File

@@ -7,6 +7,7 @@ PORT=8080
# Supabase (required for database access)
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_KEY=
# Claude API (required for AI features)
ANTHROPIC_API_KEY=

1
.gitignore vendored
View File

@@ -45,3 +45,4 @@ tmp/
# TypeScript
*.tsbuildinfo
.worktrees/

4
.m/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
workers.json
spawn.lock
session.yaml
config.reference.yaml

168
.m/config.yaml Normal file
View File

@@ -0,0 +1,168 @@
provider: claude
providers:
claude:
api_key: ""
model: claude-sonnet-4-20250514
base_url: https://api.anthropic.com/v1
ollama:
host: http://localhost:11434
model: llama3.2
memory:
enabled: true
backend: ""
path: ""
url: postgres://mai_memory.your-tenant-id:maiMem6034supa@100.99.98.201:6543/postgres?sslmode=disable
group_id: ""
cache_ttl: 5m0s
auto_load: true
embedding_url: ""
embedding_model: ""
gitea:
url: https://mgit.msbls.de
repo: m/KanzlAI
token: ""
sync:
enabled: false
interval: 0s
repos: []
auto_queue: false
api:
api_key: ""
basic_auth:
username: ""
password: ""
public_endpoints:
- /api/health
ui:
theme: default
show_sidebar: true
animation: true
persona: true
avatar_pack: ""
worker:
names: []
name_scheme: role
default_level: standard
auto_discard: false
max_workers: 5
persistent: true
head:
name: ingeborg
max_loops: 50
infinity_mode: false
capacity:
global:
max_workers: 5
max_heads: 3
per_worker:
max_tasks_lifetime: 0
max_concurrent: 1
max_context_tokens: 0
per_head:
max_workers: 10
resources:
max_memory_mb: 0
max_cpu_percent: 0
queue:
max_pending: 100
stale_task_days: 30
workforce:
timeouts:
task_default: 0s
task_max: 0s
idle_before_warn: 10m0s
idle_before_kill: 30m0s
quality_check: 2m0s
context:
max_tokens_per_worker: 0
max_tokens_global: 0
warn_threshold: 0.8
truncate_strategy: oldest
delegation:
strategy: skill_match
preferred_role: coder
auto_delegate: false
max_depth: 3
allowed_roles:
- coder
- researcher
- fixer
peppy:
enabled: false
style: calm
interval: 5m0s
emoji: false
nudges: true
nudge_main: false
custom_prompt: ""
stall_threshold: 0s
restart_enabled: false
max_shifts: 0
quality_gates:
enabled: true
checks: []
preflight:
enabled: false
type: ""
root: ""
checks: []
guardrails:
enabled: false
use_defaults: true
output:
coder_checks: []
researcher_checks: []
fixer_checks: []
custom_checks: {}
global_checks: []
tools:
role_rules: {}
deny_patterns: []
allow_patterns: []
schemas:
report_schemas: {}
deliverable_schemas: {}
modes:
yolo: false
self_improvement: false
autonomous: false
verbose: false
improve_interval: 0s
predict_interval: 0s
layouts:
head: ""
worker: ""
roles: {}
dog:
name: buddy
supabase:
url: ""
role_key: ""
anon_key: ""
schema: mai
storage:
backend: ""
postgres:
url: ""
max_conns: 0
min_conns: 0
max_conn_lifetime: 0s
idle:
behavior: wait
auto_hire: false
prompt: ""
git:
worktrees:
enabled: true
delete_branch: false
dir: .worktrees
phase:
enabled: false
current: ""
allowed_roles: {}
goal: ""
skills: {}
editor: nvim
log_level: info
project_detection: true
tone: professional

22
.mcp.json Normal file
View File

@@ -0,0 +1,22 @@
{
"mcpServers": {
"mai": {
"type": "http",
"url": "http://100.99.98.201:8000/mcp",
"headers": {
"Authorization": "Basic ${SUPABASE_AUTH}"
}
},
"mai-memory": {
"command": "mai",
"args": [
"mcp",
"memory"
],
"env": {
"MAI_MEMORY_EMBEDDING_MODEL": "nomic-embed-text",
"MAI_MEMORY_EMBEDDING_URL": "https://llm.x.msbls.de"
}
}
}
}

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.claude/CLAUDE.md

View File

@@ -8,6 +8,7 @@ import (
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
func main() {
@@ -23,7 +24,13 @@ func main() {
defer database.Close()
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
handler := router.New(database, authMW)
// Start CalDAV sync service
calDAVSvc := services.NewCalDAVService(database)
calDAVSvc.Start()
defer calDAVSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc)
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -3,8 +3,20 @@ module mgit.msbls.de/m/KanzlAI-mGMT
go 1.25.5
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/lib/pq v1.12.0 // indirect
github.com/anthropics/anthropic-sdk-go v1.27.1
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608
github.com/emersion/go-webdav v0.7.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.0
)
require (
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
golang.org/x/sync v0.16.0 // indirect
)

View File

@@ -1,4 +1,18 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/anthropics/anthropic-sdk-go v1.27.1 h1:7DgMZ2Ng3C2mPzJGHA30NXQTZolcF07mHd0tGaLwfzk=
github.com/anthropics/anthropic-sdk-go v1.27.1/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608 h1:5XWaET4YAcppq3l1/Yh2ay5VmQjUdq6qhJuucdGbmOY=
github.com/emersion/go-ical v0.0.0-20250609112844-439c63cef608/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw=
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA=
github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -9,4 +23,27 @@ github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,61 @@
package auth
import (
"context"
"fmt"
"net/http"
"github.com/google/uuid"
)
// TenantLookup resolves the default tenant for a user.
// Defined as an interface to avoid circular dependency with services.
type TenantLookup interface {
FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error)
}
// TenantResolver is middleware that resolves the tenant from X-Tenant-ID header
// or defaults to the user's first tenant.
type TenantResolver struct {
lookup TenantLookup
}
func NewTenantResolver(lookup TenantLookup) *TenantResolver {
return &TenantResolver{lookup: lookup}
}
func (tr *TenantResolver) Resolve(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var tenantID uuid.UUID
if header := r.Header.Get("X-Tenant-ID"); header != "" {
parsed, err := uuid.Parse(header)
if err != nil {
http.Error(w, fmt.Sprintf("invalid X-Tenant-ID: %v", err), http.StatusBadRequest)
return
}
tenantID = parsed
} else {
// Default to user's first tenant
first, err := tr.lookup.FirstTenantForUser(r.Context(), userID)
if err != nil {
http.Error(w, fmt.Sprintf("resolving tenant: %v", err), http.StatusInternalServerError)
return
}
if first == nil {
http.Error(w, "no tenant found for user", http.StatusBadRequest)
return
}
tenantID = *first
}
ctx := ContextWithTenantID(r.Context(), tenantID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,124 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type mockTenantLookup struct {
tenantID *uuid.UUID
err error
}
func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
return m.tenantID, m.err
}
func TestTenantResolver_FromHeader(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, ok := TenantFromContext(r.Context())
if !ok {
t.Fatal("tenant ID not in context")
}
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", tenantID.String())
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_DefaultsToFirst(t *testing.T) {
tenantID := uuid.New()
tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID})
var gotTenantID uuid.UUID
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, _ := TenantFromContext(r.Context())
gotTenantID = id
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotTenantID != tenantID {
t.Errorf("expected tenant %s, got %s", tenantID, gotTenantID)
}
}
func TestTenantResolver_NoUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestTenantResolver_InvalidHeader(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r.Header.Set("X-Tenant-ID", "not-a-uuid")
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestTenantResolver_NoTenantForUser(t *testing.T) {
tr := NewTenantResolver(&mockTenantLookup{tenantID: nil})
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
})
r := httptest.NewRequest("GET", "/api/cases", nil)
r = r.WithContext(ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
tr.Resolve(next).ServeHTTP(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}

View File

@@ -10,6 +10,7 @@ type Config struct {
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
SupabaseServiceKey string
SupabaseJWTSecret string
AnthropicAPIKey string
}
@@ -20,6 +21,7 @@ func Load() (*Config, error) {
DatabaseURL: os.Getenv("DATABASE_URL"),
SupabaseURL: os.Getenv("SUPABASE_URL"),
SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"),
SupabaseServiceKey: os.Getenv("SUPABASE_SERVICE_KEY"),
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
}

View File

@@ -0,0 +1,115 @@
package handlers
import (
"encoding/json"
"io"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AIHandler struct {
ai *services.AIService
db *sqlx.DB
}
func NewAIHandler(ai *services.AIService, db *sqlx.DB) *AIHandler {
return &AIHandler{ai: ai, db: db}
}
// ExtractDeadlines handles POST /api/ai/extract-deadlines
// Accepts either multipart/form-data with a "file" PDF field, or JSON {"text": "..."}.
func (h *AIHandler) ExtractDeadlines(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
var pdfData []byte
var text string
// Check if multipart (PDF upload)
if len(contentType) >= 9 && contentType[:9] == "multipart" {
if err := r.ParseMultipartForm(32 << 20); err != nil { // 32MB max
writeError(w, http.StatusBadRequest, "failed to parse multipart form")
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing 'file' field in multipart form")
return
}
defer file.Close()
pdfData, err = io.ReadAll(file)
if err != nil {
writeError(w, http.StatusBadRequest, "failed to read uploaded file")
return
}
} else {
// Assume JSON body
var body struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
text = body.Text
}
if len(pdfData) == 0 && text == "" {
writeError(w, http.StatusBadRequest, "provide either a PDF file or text")
return
}
deadlines, err := h.ai.ExtractDeadlines(r.Context(), pdfData, text)
if err != nil {
writeError(w, http.StatusInternalServerError, "AI extraction failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deadlines": deadlines,
"count": len(deadlines),
})
}
// SummarizeCase handles POST /api/ai/summarize-case
// Accepts JSON {"case_id": "uuid"}.
func (h *AIHandler) SummarizeCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
var body struct {
CaseID string `json:"case_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.CaseID == "" {
writeError(w, http.StatusBadRequest, "case_id is required")
return
}
caseID, err := parseUUID(body.CaseID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
summary, err := h.ai.SummarizeCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, "AI summarization failed: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"case_id": caseID.String(),
"summary": summary,
})
}

View File

@@ -0,0 +1,205 @@
package handlers
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type AppointmentHandler struct {
svc *services.AppointmentService
}
func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler {
return &AppointmentHandler{svc: svc}
}
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
filter := services.AppointmentFilter{}
if v := r.URL.Query().Get("case_id"); v != "" {
id, err := uuid.Parse(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case_id")
return
}
filter.CaseID = &id
}
if v := r.URL.Query().Get("type"); v != "" {
filter.Type = &v
}
if v := r.URL.Query().Get("start_from"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_from (use RFC3339)")
return
}
filter.StartFrom = &t
}
if v := r.URL.Query().Get("start_to"); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid start_to (use RFC3339)")
return
}
filter.StartTo = &t
}
appointments, err := h.svc.List(r.Context(), tenantID, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list appointments")
return
}
writeJSON(w, http.StatusOK, appointments)
}
type createAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Create(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
var req createAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
appt := &models.Appointment{
TenantID: tenantID,
CaseID: req.CaseID,
Title: req.Title,
Description: req.Description,
StartAt: req.StartAt,
EndAt: req.EndAt,
Location: req.Location,
AppointmentType: req.AppointmentType,
}
if err := h.svc.Create(r.Context(), appt); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create appointment")
return
}
writeJSON(w, http.StatusCreated, appt)
}
type updateAppointmentRequest struct {
CaseID *uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at"`
Location *string `json:"location"`
AppointmentType *string `json:"appointment_type"`
}
func (h *AppointmentHandler) Update(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
// Fetch existing to verify ownership
existing, err := h.svc.GetByID(r.Context(), tenantID, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
return
}
var req updateAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title is required")
return
}
if req.StartAt.IsZero() {
writeError(w, http.StatusBadRequest, "start_at is required")
return
}
existing.CaseID = req.CaseID
existing.Title = req.Title
existing.Description = req.Description
existing.StartAt = req.StartAt
existing.EndAt = req.EndAt
existing.Location = req.Location
existing.AppointmentType = req.AppointmentType
if err := h.svc.Update(r.Context(), existing); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update appointment")
return
}
writeJSON(w, http.StatusOK, existing)
}
func (h *AppointmentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "missing tenant")
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid appointment id")
return
}
if err := h.svc.Delete(r.Context(), tenantID, id); err != nil {
writeError(w, http.StatusNotFound, "appointment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -0,0 +1,89 @@
package handlers
import (
"encoding/json"
"net/http"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalculateHandlers holds handlers for deadline calculation endpoints
type CalculateHandlers struct {
calculator *services.DeadlineCalculator
rules *services.DeadlineRuleService
}
// NewCalculateHandlers creates calculate handlers
func NewCalculateHandlers(calc *services.DeadlineCalculator, rules *services.DeadlineRuleService) *CalculateHandlers {
return &CalculateHandlers{calculator: calc, rules: rules}
}
// CalculateRequest is the input for POST /api/deadlines/calculate
type CalculateRequest struct {
ProceedingType string `json:"proceeding_type"`
TriggerEventDate string `json:"trigger_event_date"`
SelectedRuleIDs []string `json:"selected_rule_ids,omitempty"`
}
// Calculate handles POST /api/deadlines/calculate
func (h *CalculateHandlers) Calculate(w http.ResponseWriter, r *http.Request) {
var req CalculateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.ProceedingType == "" || req.TriggerEventDate == "" {
writeError(w, http.StatusBadRequest, "proceeding_type and trigger_event_date are required")
return
}
eventDate, err := time.Parse("2006-01-02", req.TriggerEventDate)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid trigger_event_date format, expected YYYY-MM-DD")
return
}
var results []services.CalculatedDeadline
if len(req.SelectedRuleIDs) > 0 {
ruleModels, err := h.rules.GetByIDs(req.SelectedRuleIDs)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch selected rules")
return
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
} else {
tree, err := h.rules.GetRuleTree(req.ProceedingType)
if err != nil {
writeError(w, http.StatusBadRequest, "unknown proceeding type")
return
}
// Flatten tree to get all rule models
var flatNodes []services.RuleTreeNode
flattenTree(tree, &flatNodes)
ruleModels := make([]models.DeadlineRule, 0, len(flatNodes))
for _, node := range flatNodes {
ruleModels = append(ruleModels, node.DeadlineRule)
}
results = h.calculator.CalculateFromRules(eventDate, ruleModels)
}
writeJSON(w, http.StatusOK, map[string]any{
"proceeding_type": req.ProceedingType,
"trigger_event_date": req.TriggerEventDate,
"deadlines": results,
})
}
func flattenTree(nodes []services.RuleTreeNode, result *[]services.RuleTreeNode) {
for _, n := range nodes {
*result = append(*result, n)
if len(n.Children) > 0 {
flattenTree(n.Children, result)
}
}
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// CalDAVHandler handles CalDAV sync HTTP endpoints.
type CalDAVHandler struct {
svc *services.CalDAVService
}
// NewCalDAVHandler creates a new CalDAV handler.
func NewCalDAVHandler(svc *services.CalDAVService) *CalDAVHandler {
return &CalDAVHandler{svc: svc}
}
// TriggerSync handles POST /api/caldav/sync — triggers a full sync for the current tenant.
func (h *CalDAVHandler) TriggerSync(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
cfg, err := h.svc.LoadTenantConfig(tenantID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
status, err := h.svc.SyncTenant(r.Context(), tenantID, *cfg)
if err != nil {
// Still return the status — it contains partial results + error info
writeJSON(w, http.StatusOK, map[string]any{
"status": "completed_with_errors",
"sync": status,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"sync": status,
})
}
// GetStatus handles GET /api/caldav/status — returns last sync status.
func (h *CalDAVHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "no tenant context")
return
}
status := h.svc.GetStatus(tenantID)
if status == nil {
writeJSON(w, http.StatusOK, map[string]any{
"status": "no_sync_yet",
"last_sync_at": nil,
})
return
}
writeJSON(w, http.StatusOK, status)
}

View File

@@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type DashboardHandler struct {
svc *services.DashboardService
}
func NewDashboardHandler(svc *services.DashboardService) *DashboardHandler {
return &DashboardHandler{svc: svc}
}
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
data, err := h.svc.Get(r.Context(), tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, data)
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
"strconv"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineRuleHandlers holds handlers for deadline rule endpoints
type DeadlineRuleHandlers struct {
rules *services.DeadlineRuleService
}
// NewDeadlineRuleHandlers creates deadline rule handlers
func NewDeadlineRuleHandlers(rs *services.DeadlineRuleService) *DeadlineRuleHandlers {
return &DeadlineRuleHandlers{rules: rs}
}
// List handles GET /api/deadline-rules
// Query params: proceeding_type_id (optional int filter)
func (h *DeadlineRuleHandlers) List(w http.ResponseWriter, r *http.Request) {
var proceedingTypeID *int
if v := r.URL.Query().Get("proceeding_type_id"); v != "" {
id, err := strconv.Atoi(v)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid proceeding_type_id")
return
}
proceedingTypeID = &id
}
rules, err := h.rules.List(proceedingTypeID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadline rules")
return
}
writeJSON(w, http.StatusOK, rules)
}
// ListProceedingTypes handles GET /api/proceeding-types
func (h *DeadlineRuleHandlers) ListProceedingTypes(w http.ResponseWriter, r *http.Request) {
types, err := h.rules.ListProceedingTypes()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list proceeding types")
return
}
writeJSON(w, http.StatusOK, types)
}
// GetRuleTree handles GET /api/deadline-rules/{type}
// {type} is the proceeding type code (e.g., "INF", "REV")
func (h *DeadlineRuleHandlers) GetRuleTree(w http.ResponseWriter, r *http.Request) {
typeCode := r.PathValue("type")
if typeCode == "" {
writeError(w, http.StatusBadRequest, "proceeding type code required")
return
}
tree, err := h.rules.GetRuleTree(typeCode)
if err != nil {
writeError(w, http.StatusNotFound, "proceeding type not found")
return
}
writeJSON(w, http.StatusOK, tree)
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
// DeadlineHandlers holds handlers for deadline CRUD endpoints
type DeadlineHandlers struct {
deadlines *services.DeadlineService
db *sqlx.DB
}
// NewDeadlineHandlers creates deadline handlers
func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHandlers {
return &DeadlineHandlers{deadlines: ds, db: db}
}
// ListAll handles GET /api/deadlines
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlines, err := h.deadlines.ListAll(tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// ListForCase handles GET /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) ListForCase(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
deadlines, err := h.deadlines.ListForCase(tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list deadlines")
return
}
writeJSON(w, http.StatusOK, deadlines)
}
// Create handles POST /api/cases/{caseID}/deadlines
func (h *DeadlineHandlers) Create(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
caseID, err := parsePathUUID(r, "caseID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
var input services.CreateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
input.CaseID = caseID
if input.Title == "" || input.DueDate == "" {
writeError(w, http.StatusBadRequest, "title and due_date are required")
return
}
deadline, err := h.deadlines.Create(tenantID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create deadline")
return
}
writeJSON(w, http.StatusCreated, deadline)
}
// Update handles PUT /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Update(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
var input services.UpdateDeadlineInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
deadline, err := h.deadlines.Update(tenantID, deadlineID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to update deadline")
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Complete handles PATCH /api/deadlines/{deadlineID}/complete
func (h *DeadlineHandlers) Complete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
deadline, err := h.deadlines.Complete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to complete deadline")
return
}
if deadline == nil {
writeError(w, http.StatusNotFound, "deadline not found")
return
}
writeJSON(w, http.StatusOK, deadline)
}
// Delete handles DELETE /api/deadlines/{deadlineID}
func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, err := resolveTenant(r, h.db)
if err != nil {
handleTenantError(w, err)
return
}
deadlineID, err := parsePathUUID(r, "deadlineID")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid deadline ID")
return
}
err = h.deadlines.Delete(tenantID, deadlineID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -0,0 +1,183 @@
package handlers
import (
"fmt"
"io"
"net/http"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/google/uuid"
)
const maxUploadSize = 50 << 20 // 50 MB
type DocumentHandler struct {
svc *services.DocumentService
}
func NewDocumentHandler(svc *services.DocumentService) *DocumentHandler {
return &DocumentHandler{svc: svc}
}
func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"documents": docs,
"total": len(docs),
})
}
func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
caseID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid case ID")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, "missing file field")
return
}
defer file.Close()
title := r.FormValue("title")
if title == "" {
title = header.Filename
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
input := services.CreateDocumentInput{
Title: title,
DocType: r.FormValue("doc_type"),
Filename: header.Filename,
ContentType: contentType,
Size: int(header.Size),
Data: file,
}
doc, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
if err != nil {
if err.Error() == "case not found" {
writeError(w, http.StatusNotFound, "case not found")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, doc)
}
func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
if err != nil {
if err.Error() == "document not found" || err.Error() == "document has no file" {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer body.Close()
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
io.Copy(w, body)
}
func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if doc == nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, doc)
}
func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
tenantID, ok := auth.TenantFromContext(r.Context())
if !ok {
writeError(w, http.StatusForbidden, "missing tenant")
return
}
userID, _ := auth.UserFromContext(r.Context())
docID, err := uuid.Parse(r.PathValue("docId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document ID")
return
}
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
writeError(w, http.StatusNotFound, "document not found")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}

View File

@@ -3,14 +3,88 @@ package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// resolveTenant gets the tenant ID for the authenticated user.
// Checks X-Tenant-ID header first, then falls back to user's first tenant.
func resolveTenant(r *http.Request, db *sqlx.DB) (uuid.UUID, error) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
return uuid.Nil, errUnauthorized
}
// Check header first
if headerVal := r.Header.Get("X-Tenant-ID"); headerVal != "" {
tenantID, err := uuid.Parse(headerVal)
if err != nil {
return uuid.Nil, errInvalidTenant
}
// Verify user has access to this tenant
var count int
err = db.Get(&count,
`SELECT COUNT(*) FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID)
if err != nil || count == 0 {
return uuid.Nil, errTenantAccess
}
return tenantID, nil
}
// Fall back to user's first tenant
var tenantID uuid.UUID
err := db.Get(&tenantID,
`SELECT tenant_id FROM user_tenants WHERE user_id = $1 ORDER BY created_at LIMIT 1`,
userID)
if err != nil {
return uuid.Nil, errNoTenant
}
return tenantID, nil
}
type apiError struct {
msg string
status int
}
func (e *apiError) Error() string { return e.msg }
var (
errUnauthorized = &apiError{msg: "unauthorized", status: http.StatusUnauthorized}
errInvalidTenant = &apiError{msg: "invalid tenant ID", status: http.StatusBadRequest}
errTenantAccess = &apiError{msg: "no access to tenant", status: http.StatusForbidden}
errNoTenant = &apiError{msg: "no tenant found for user", status: http.StatusBadRequest}
)
// handleTenantError writes the appropriate error response for tenant resolution errors
func handleTenantError(w http.ResponseWriter, err error) {
if ae, ok := err.(*apiError); ok {
writeError(w, ae.status, ae.msg)
return
}
writeError(w, http.StatusInternalServerError, "internal error")
}
// parsePathUUID extracts a UUID from the URL path using PathValue
func parsePathUUID(r *http.Request, key string) (uuid.UUID, error) {
return uuid.Parse(r.PathValue(key))
}
// parseUUID parses a UUID string
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}

View File

@@ -0,0 +1,283 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
)
type TenantHandler struct {
svc *services.TenantService
}
func NewTenantHandler(svc *services.TenantService) *TenantHandler {
return &TenantHandler{svc: svc}
}
// CreateTenant handles POST /api/tenants
func (h *TenantHandler) CreateTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" || req.Slug == "" {
jsonError(w, "name and slug are required", http.StatusBadRequest)
return
}
tenant, err := h.svc.Create(r.Context(), userID, req.Name, req.Slug)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, tenant, http.StatusCreated)
}
// ListTenants handles GET /api/tenants
func (h *TenantHandler) ListTenants(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenants, err := h.svc.ListForUser(r.Context(), userID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, tenants, http.StatusOK)
}
// GetTenant handles GET /api/tenants/{id}
func (h *TenantHandler) GetTenant(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access to this tenant
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
tenant, err := h.svc.GetByID(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if tenant == nil {
jsonError(w, "not found", http.StatusNotFound)
return
}
jsonResponse(w, tenant, http.StatusOK)
}
// InviteUser handles POST /api/tenants/{id}/invite
func (h *TenantHandler) InviteUser(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Only owners and admins can invite
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can invite users", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
jsonError(w, "email is required", http.StatusBadRequest)
return
}
if req.Role == "" {
req.Role = "member"
}
if req.Role != "member" && req.Role != "admin" {
jsonError(w, "role must be member or admin", http.StatusBadRequest)
return
}
ut, err := h.svc.InviteByEmail(r.Context(), tenantID, req.Email, req.Role)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, ut, http.StatusCreated)
}
// RemoveMember handles DELETE /api/tenants/{id}/members/{uid}
func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
memberID, err := uuid.Parse(r.PathValue("uid"))
if err != nil {
jsonError(w, "invalid member ID", http.StatusBadRequest)
return
}
// Only owners and admins can remove members (or user removing themselves)
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" && userID != memberID {
jsonError(w, "insufficient permissions", http.StatusForbidden)
return
}
if err := h.svc.RemoveMember(r.Context(), tenantID, memberID); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
jsonResponse(w, map[string]string{"status": "removed"}, http.StatusOK)
}
// UpdateSettings handles PUT /api/tenants/{id}/settings
func (h *TenantHandler) UpdateSettings(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Only owners and admins can update settings
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role != "owner" && role != "admin" {
jsonError(w, "only owners and admins can update settings", http.StatusForbidden)
return
}
var settings json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
jsonError(w, "invalid request body", http.StatusBadRequest)
return
}
tenant, err := h.svc.UpdateSettings(r.Context(), tenantID, settings)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, tenant, http.StatusOK)
}
// ListMembers handles GET /api/tenants/{id}/members
func (h *TenantHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
userID, ok := auth.UserFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tenantID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
jsonError(w, "invalid tenant ID", http.StatusBadRequest)
return
}
// Verify user has access
role, err := h.svc.GetUserRole(r.Context(), userID, tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if role == "" {
jsonError(w, "not found", http.StatusNotFound)
return
}
members, err := h.svc.ListMembers(r.Context(), tenantID)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, members, http.StatusOK)
}
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}

View File

@@ -0,0 +1,132 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
)
func TestCreateTenant_MissingFields(t *testing.T) {
h := &TenantHandler{} // no service needed for validation
// Build request with auth context
body := `{"name":"","slug":""}`
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(body))
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "name and slug are required" {
t.Errorf("unexpected error: %s", resp["error"])
}
}
func TestCreateTenant_NoAuth(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("POST", "/api/tenants", bytes.NewBufferString(`{}`))
w := httptest.NewRecorder()
h.CreateTenant(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestGetTenant_InvalidID(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("GET", "/api/tenants/not-a-uuid", nil)
r.SetPathValue("id", "not-a-uuid")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.GetTenant(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_InvalidTenantID(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com","role":"member"}`
r := httptest.NewRequest("POST", "/api/tenants/bad/invite", bytes.NewBufferString(body))
r.SetPathValue("id", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestInviteUser_NoAuth(t *testing.T) {
h := &TenantHandler{}
body := `{"email":"test@example.com"}`
r := httptest.NewRequest("POST", "/api/tenants/"+uuid.New().String()+"/invite", bytes.NewBufferString(body))
r.SetPathValue("id", uuid.New().String())
w := httptest.NewRecorder()
h.InviteUser(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", w.Code)
}
}
func TestRemoveMember_InvalidIDs(t *testing.T) {
h := &TenantHandler{}
r := httptest.NewRequest("DELETE", "/api/tenants/bad/members/bad", nil)
r.SetPathValue("id", "bad")
r.SetPathValue("uid", "bad")
r = r.WithContext(auth.ContextWithUserID(r.Context(), uuid.New()))
w := httptest.NewRecorder()
h.RemoveMember(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestJsonResponse(t *testing.T) {
w := httptest.NewRecorder()
jsonResponse(w, map[string]string{"key": "value"}, http.StatusOK)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json, got %s", ct)
}
}
func TestJsonError(t *testing.T) {
w := httptest.NewRecorder()
jsonError(w, "something went wrong", http.StatusBadRequest)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["error"] != "something went wrong" {
t.Errorf("unexpected error: %s", resp["error"])
}
}

View File

@@ -22,3 +22,9 @@ type UserTenant struct {
Role string `db:"role" json:"role"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// TenantWithRole is a Tenant joined with the user's role in that tenant.
type TenantWithRole struct {
Tenant
Role string `db:"role" json:"role"`
}

View File

@@ -4,23 +4,51 @@ import (
"encoding/json"
"net/http"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
"github.com/jmoiron/sqlx"
)
func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler {
mux := http.NewServeMux()
// Services
tenantSvc := services.NewTenantService(db)
caseSvc := services.NewCaseService(db)
partySvc := services.NewPartyService(db)
appointmentSvc := services.NewAppointmentService(db)
holidaySvc := services.NewHolidayService(db)
deadlineSvc := services.NewDeadlineService(db)
deadlineRuleSvc := services.NewDeadlineRuleService(db)
calculator := services.NewDeadlineCalculator(holidaySvc)
storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey)
documentSvc := services.NewDocumentService(db, storageCli)
// AI service (optional — only if API key is configured)
var aiH *handlers.AIHandler
if cfg.AnthropicAPIKey != "" {
aiSvc := services.NewAIService(cfg.AnthropicAPIKey, db)
aiH = handlers.NewAIHandler(aiSvc, db)
}
// Middleware
tenantResolver := auth.NewTenantResolver(tenantSvc)
dashboardSvc := services.NewDashboardService(db)
// Handlers
tenantH := handlers.NewTenantHandler(tenantSvc)
caseH := handlers.NewCaseHandler(caseSvc)
partyH := handlers.NewPartyHandler(partySvc)
apptH := handlers.NewAppointmentHandler(appointmentSvc)
deadlineH := handlers.NewDeadlineHandlers(deadlineSvc, db)
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
docH := handlers.NewDocumentHandler(documentSvc)
// Public routes
mux.HandleFunc("GET /health", handleHealth(db))
@@ -28,23 +56,78 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
// Authenticated API routes
api := http.NewServeMux()
// Tenant management (no tenant resolver — these operate across tenants)
api.HandleFunc("POST /api/tenants", tenantH.CreateTenant)
api.HandleFunc("GET /api/tenants", tenantH.ListTenants)
api.HandleFunc("GET /api/tenants/{id}", tenantH.GetTenant)
api.HandleFunc("PUT /api/tenants/{id}/settings", tenantH.UpdateSettings)
api.HandleFunc("POST /api/tenants/{id}/invite", tenantH.InviteUser)
api.HandleFunc("DELETE /api/tenants/{id}/members/{uid}", tenantH.RemoveMember)
api.HandleFunc("GET /api/tenants/{id}/members", tenantH.ListMembers)
// Tenant-scoped routes (require tenant context)
scoped := http.NewServeMux()
// Cases
api.HandleFunc("GET /api/cases", caseH.List)
api.HandleFunc("POST /api/cases", caseH.Create)
api.HandleFunc("GET /api/cases/{id}", caseH.Get)
api.HandleFunc("PUT /api/cases/{id}", caseH.Update)
api.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
scoped.HandleFunc("GET /api/cases", caseH.List)
scoped.HandleFunc("POST /api/cases", caseH.Create)
scoped.HandleFunc("GET /api/cases/{id}", caseH.Get)
scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update)
scoped.HandleFunc("DELETE /api/cases/{id}", caseH.Delete)
// Parties (nested under cases for creation/listing, top-level for update/delete)
api.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
api.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
api.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
api.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Parties
scoped.HandleFunc("GET /api/cases/{id}/parties", partyH.List)
scoped.HandleFunc("POST /api/cases/{id}/parties", partyH.Create)
scoped.HandleFunc("PUT /api/parties/{partyId}", partyH.Update)
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// Placeholder routes for future phases
api.HandleFunc("GET /api/deadlines", placeholder("deadlines"))
api.HandleFunc("GET /api/appointments", placeholder("appointments"))
api.HandleFunc("GET /api/documents", placeholder("documents"))
// Deadlines
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
scoped.HandleFunc("PUT /api/deadlines/{deadlineID}", deadlineH.Update)
scoped.HandleFunc("PATCH /api/deadlines/{deadlineID}/complete", deadlineH.Complete)
scoped.HandleFunc("DELETE /api/deadlines/{deadlineID}", deadlineH.Delete)
// Deadline rules (reference data)
scoped.HandleFunc("GET /api/deadline-rules", ruleH.List)
scoped.HandleFunc("GET /api/deadline-rules/{type}", ruleH.GetRuleTree)
scoped.HandleFunc("GET /api/proceeding-types", ruleH.ListProceedingTypes)
// Deadline calculator
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
// Appointments
scoped.HandleFunc("GET /api/appointments", apptH.List)
scoped.HandleFunc("POST /api/appointments", apptH.Create)
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
// Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Documents
scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase)
scoped.HandleFunc("POST /api/cases/{id}/documents", docH.Upload)
scoped.HandleFunc("GET /api/documents/{docId}", docH.Download)
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
// AI endpoints
if aiH != nil {
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiH.ExtractDeadlines)
scoped.HandleFunc("POST /api/ai/summarize-case", aiH.SummarizeCase)
}
// CalDAV sync endpoints
if calDAVSvc != nil {
calDAVH := handlers.NewCalDAVHandler(calDAVSvc)
scoped.HandleFunc("POST /api/caldav/sync", calDAVH.TriggerSync)
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}
// Wire: auth -> tenant routes go directly, scoped routes get tenant resolver
api.Handle("/api/", tenantResolver.Resolve(scoped))
mux.Handle("/api/", authMW.RequireAuth(api))
@@ -63,12 +146,3 @@ func handleHealth(db *sqlx.DB) http.HandlerFunc {
}
}
func placeholder(resource string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "not_implemented",
"resource": resource,
})
}
}

View File

@@ -0,0 +1,283 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AIService struct {
client anthropic.Client
db *sqlx.DB
}
func NewAIService(apiKey string, db *sqlx.DB) *AIService {
client := anthropic.NewClient(option.WithAPIKey(apiKey))
return &AIService{client: client, db: db}
}
// ExtractedDeadline represents a deadline extracted by AI from a document.
type ExtractedDeadline struct {
Title string `json:"title"`
DueDate *string `json:"due_date"`
DurationValue int `json:"duration_value"`
DurationUnit string `json:"duration_unit"`
Timing string `json:"timing"`
TriggerEvent string `json:"trigger_event"`
RuleReference string `json:"rule_reference"`
Confidence float64 `json:"confidence"`
SourceQuote string `json:"source_quote"`
}
type extractDeadlinesToolInput struct {
Deadlines []ExtractedDeadline `json:"deadlines"`
}
var deadlineExtractionTool = anthropic.ToolParam{
Name: "extract_deadlines",
Description: anthropic.String("Extract all legal deadlines found in the document. Return each deadline with its details."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"deadlines": map[string]any{
"type": "array",
"description": "List of extracted deadlines",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{
"type": "string",
"description": "Short title describing the deadline (e.g. 'Statement of Defence', 'Reply to Counterclaim')",
},
"due_date": map[string]any{
"type": []string{"string", "null"},
"description": "Absolute due date in YYYY-MM-DD format if determinable, null otherwise",
},
"duration_value": map[string]any{
"type": "integer",
"description": "Numeric duration value (e.g. 3 for '3 months')",
},
"duration_unit": map[string]any{
"type": "string",
"enum": []string{"days", "weeks", "months"},
"description": "Unit of the duration period",
},
"timing": map[string]any{
"type": "string",
"enum": []string{"after", "before"},
"description": "Whether the deadline is before or after the trigger event",
},
"trigger_event": map[string]any{
"type": "string",
"description": "The event that triggers this deadline (e.g. 'service of the Statement of Claim')",
},
"rule_reference": map[string]any{
"type": "string",
"description": "Legal rule reference (e.g. 'Rule 23 RoP', 'Rule 222 RoP', '§ 276 ZPO')",
},
"confidence": map[string]any{
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score from 0.0 to 1.0",
},
"source_quote": map[string]any{
"type": "string",
"description": "The exact quote from the document where this deadline was found",
},
},
"required": []string{"title", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"},
},
},
},
Required: []string{"deadlines"},
},
}
const extractionSystemPrompt = `You are a legal deadline extraction assistant for German and UPC (Unified Patent Court) patent litigation.
Your task is to extract all legal deadlines, time limits, and procedural time periods from the provided document.
For each deadline found, extract:
- A clear title describing the deadline
- The absolute due date if it can be determined from the document
- The duration (value + unit: days/weeks/months)
- Whether it runs before or after a trigger event
- The trigger event that starts the deadline
- The legal rule reference (e.g. Rule 23 RoP, § 276 ZPO)
- Your confidence level (0.0-1.0) in the extraction
- The exact source quote from the document
Be thorough: extract every deadline mentioned, including conditional ones. If a deadline references another deadline (e.g. "within 2 months of the defence"), capture that relationship in the trigger_event field.
If the document contains no deadlines, return an empty list.`
// ExtractDeadlines sends a document (PDF or text) to Claude for deadline extraction.
func (s *AIService) ExtractDeadlines(ctx context.Context, pdfData []byte, text string) ([]ExtractedDeadline, error) {
var contentBlocks []anthropic.ContentBlockParamUnion
if len(pdfData) > 0 {
encoded := base64.StdEncoding.EncodeToString(pdfData)
contentBlocks = append(contentBlocks, anthropic.ContentBlockParamUnion{
OfDocument: &anthropic.DocumentBlockParam{
Source: anthropic.DocumentBlockParamSourceUnion{
OfBase64: &anthropic.Base64PDFSourceParam{
Data: encoded,
},
},
},
})
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from this document."))
} else if text != "" {
contentBlocks = append(contentBlocks, anthropic.NewTextBlock("Extract all legal deadlines from the following text:\n\n"+text))
} else {
return nil, fmt.Errorf("either pdf_data or text must be provided")
}
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet4_5,
MaxTokens: 4096,
System: []anthropic.TextBlockParam{
{Text: extractionSystemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(contentBlocks...),
},
Tools: []anthropic.ToolUnionParam{
{OfTool: &deadlineExtractionTool},
},
ToolChoice: anthropic.ToolChoiceParamOfTool("extract_deadlines"),
})
if err != nil {
return nil, fmt.Errorf("claude API call: %w", err)
}
// Find the tool_use block in the response
for _, block := range msg.Content {
if block.Type == "tool_use" && block.Name == "extract_deadlines" {
var input extractDeadlinesToolInput
if err := json.Unmarshal(block.Input, &input); err != nil {
return nil, fmt.Errorf("parsing tool output: %w", err)
}
return input.Deadlines, nil
}
}
return nil, fmt.Errorf("no tool_use block in response")
}
const summarizeSystemPrompt = `You are a legal case summary assistant for German and UPC patent litigation case management.
Given a case's details, recent events, and deadlines, produce a concise 2-3 sentence summary of what matters right now. Focus on:
- The most urgent upcoming deadline
- Recent significant events
- The current procedural stage
Write in clear, professional language suitable for a lawyer reviewing their case list. Be specific about dates and deadlines.`
// SummarizeCase generates an AI summary for a case and caches it in the database.
func (s *AIService) SummarizeCase(ctx context.Context, tenantID, caseID uuid.UUID) (string, error) {
// Load case
var c models.Case
err := s.db.GetContext(ctx, &c,
"SELECT * FROM cases WHERE id = $1 AND tenant_id = $2", caseID, tenantID)
if err != nil {
return "", fmt.Errorf("loading case: %w", err)
}
// Load recent events
var events []models.CaseEvent
if err := s.db.SelectContext(ctx, &events,
"SELECT * FROM case_events WHERE case_id = $1 AND tenant_id = $2 ORDER BY created_at DESC LIMIT 10",
caseID, tenantID); err != nil {
return "", fmt.Errorf("loading events: %w", err)
}
// Load active deadlines
var deadlines []models.Deadline
if err := s.db.SelectContext(ctx, &deadlines,
"SELECT * FROM deadlines WHERE case_id = $1 AND tenant_id = $2 AND status = 'active' ORDER BY due_date ASC LIMIT 10",
caseID, tenantID); err != nil {
return "", fmt.Errorf("loading deadlines: %w", err)
}
// Build context text
caseInfo := fmt.Sprintf("Case: %s — %s\nStatus: %s", c.CaseNumber, c.Title, c.Status)
if c.Court != nil {
caseInfo += fmt.Sprintf("\nCourt: %s", *c.Court)
}
if c.CourtRef != nil {
caseInfo += fmt.Sprintf("\nCourt Reference: %s", *c.CourtRef)
}
if c.CaseType != nil {
caseInfo += fmt.Sprintf("\nType: %s", *c.CaseType)
}
eventText := "\n\nRecent Events:"
if len(events) == 0 {
eventText += "\nNo events recorded."
}
for _, e := range events {
eventText += fmt.Sprintf("\n- [%s] %s", e.CreatedAt.Format("2006-01-02"), e.Title)
if e.Description != nil {
eventText += fmt.Sprintf(": %s", *e.Description)
}
}
deadlineText := "\n\nUpcoming Deadlines:"
if len(deadlines) == 0 {
deadlineText += "\nNo active deadlines."
}
for _, d := range deadlines {
deadlineText += fmt.Sprintf("\n- %s: due %s (status: %s)", d.Title, d.DueDate, d.Status)
if d.Description != nil {
deadlineText += fmt.Sprintf(" — %s", *d.Description)
}
}
prompt := caseInfo + eventText + deadlineText
msg, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet4_5,
MaxTokens: 512,
System: []anthropic.TextBlockParam{
{Text: summarizeSystemPrompt},
},
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Summarize the current state of this case:\n\n" + prompt)),
},
})
if err != nil {
return "", fmt.Errorf("claude API call: %w", err)
}
// Extract text from response
var summary string
for _, block := range msg.Content {
if block.Type == "text" {
summary += block.Text
}
}
if summary == "" {
return "", fmt.Errorf("empty response from Claude")
}
// Cache summary in database
_, err = s.db.ExecContext(ctx,
"UPDATE cases SET ai_summary = $1, updated_at = $2 WHERE id = $3 AND tenant_id = $4",
summary, time.Now(), caseID, tenantID)
if err != nil {
return "", fmt.Errorf("caching summary: %w", err)
}
return summary, nil
}

View File

@@ -0,0 +1,109 @@
package services
import (
"encoding/json"
"testing"
)
func TestDeadlineExtractionToolSchema(t *testing.T) {
// Verify the tool schema serializes correctly
data, err := json.Marshal(deadlineExtractionTool)
if err != nil {
t.Fatalf("failed to marshal tool: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal tool JSON: %v", err)
}
if parsed["name"] != "extract_deadlines" {
t.Errorf("expected name 'extract_deadlines', got %v", parsed["name"])
}
schema, ok := parsed["input_schema"].(map[string]any)
if !ok {
t.Fatal("input_schema is not a map")
}
if schema["type"] != "object" {
t.Errorf("expected schema type 'object', got %v", schema["type"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatal("properties is not a map")
}
deadlines, ok := props["deadlines"].(map[string]any)
if !ok {
t.Fatal("deadlines property is not a map")
}
if deadlines["type"] != "array" {
t.Errorf("expected deadlines type 'array', got %v", deadlines["type"])
}
items, ok := deadlines["items"].(map[string]any)
if !ok {
t.Fatal("items is not a map")
}
itemProps, ok := items["properties"].(map[string]any)
if !ok {
t.Fatal("item properties is not a map")
}
expectedFields := []string{"title", "due_date", "duration_value", "duration_unit", "timing", "trigger_event", "rule_reference", "confidence", "source_quote"}
for _, field := range expectedFields {
if _, ok := itemProps[field]; !ok {
t.Errorf("missing expected field %q in item properties", field)
}
}
required, ok := items["required"].([]any)
if !ok {
t.Fatal("required is not a list")
}
if len(required) != 8 {
t.Errorf("expected 8 required fields, got %d", len(required))
}
}
func TestExtractedDeadlineJSON(t *testing.T) {
dueDate := "2026-04-15"
d := ExtractedDeadline{
Title: "Statement of Defence",
DueDate: &dueDate,
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
TriggerEvent: "service of the Statement of Claim",
RuleReference: "Rule 23 RoP",
Confidence: 0.95,
SourceQuote: "The defendant shall file a defence within 3 months",
}
data, err := json.Marshal(d)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var parsed ExtractedDeadline
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if parsed.Title != d.Title {
t.Errorf("title mismatch: %q != %q", parsed.Title, d.Title)
}
if *parsed.DueDate != *d.DueDate {
t.Errorf("due_date mismatch: %q != %q", *parsed.DueDate, *d.DueDate)
}
if parsed.DurationValue != d.DurationValue {
t.Errorf("duration_value mismatch: %d != %d", parsed.DurationValue, d.DurationValue)
}
if parsed.Confidence != d.Confidence {
t.Errorf("confidence mismatch: %f != %f", parsed.Confidence, d.Confidence)
}
}

View File

@@ -0,0 +1,135 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type AppointmentService struct {
db *sqlx.DB
}
func NewAppointmentService(db *sqlx.DB) *AppointmentService {
return &AppointmentService{db: db}
}
type AppointmentFilter struct {
CaseID *uuid.UUID
Type *string
StartFrom *time.Time
StartTo *time.Time
}
func (s *AppointmentService) List(ctx context.Context, tenantID uuid.UUID, filter AppointmentFilter) ([]models.Appointment, error) {
query := "SELECT * FROM appointments WHERE tenant_id = $1"
args := []any{tenantID}
argN := 2
if filter.CaseID != nil {
query += fmt.Sprintf(" AND case_id = $%d", argN)
args = append(args, *filter.CaseID)
argN++
}
if filter.Type != nil {
query += fmt.Sprintf(" AND appointment_type = $%d", argN)
args = append(args, *filter.Type)
argN++
}
if filter.StartFrom != nil {
query += fmt.Sprintf(" AND start_at >= $%d", argN)
args = append(args, *filter.StartFrom)
argN++
}
if filter.StartTo != nil {
query += fmt.Sprintf(" AND start_at <= $%d", argN)
args = append(args, *filter.StartTo)
argN++
}
query += " ORDER BY start_at ASC"
var appointments []models.Appointment
if err := s.db.SelectContext(ctx, &appointments, query, args...); err != nil {
return nil, fmt.Errorf("listing appointments: %w", err)
}
if appointments == nil {
appointments = []models.Appointment{}
}
return appointments, nil
}
func (s *AppointmentService) GetByID(ctx context.Context, tenantID, id uuid.UUID) (*models.Appointment, error) {
var a models.Appointment
err := s.db.GetContext(ctx, &a, "SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return nil, fmt.Errorf("getting appointment: %w", err)
}
return &a, nil
}
func (s *AppointmentService) Create(ctx context.Context, a *models.Appointment) error {
a.ID = uuid.New()
now := time.Now().UTC()
a.CreatedAt = now
a.UpdatedAt = now
_, err := s.db.NamedExecContext(ctx, `
INSERT INTO appointments (id, tenant_id, case_id, title, description, start_at, end_at, location, appointment_type, caldav_uid, caldav_etag, created_at, updated_at)
VALUES (:id, :tenant_id, :case_id, :title, :description, :start_at, :end_at, :location, :appointment_type, :caldav_uid, :caldav_etag, :created_at, :updated_at)
`, a)
if err != nil {
return fmt.Errorf("creating appointment: %w", err)
}
return nil
}
func (s *AppointmentService) Update(ctx context.Context, a *models.Appointment) error {
a.UpdatedAt = time.Now().UTC()
result, err := s.db.NamedExecContext(ctx, `
UPDATE appointments SET
case_id = :case_id,
title = :title,
description = :description,
start_at = :start_at,
end_at = :end_at,
location = :location,
appointment_type = :appointment_type,
caldav_uid = :caldav_uid,
caldav_etag = :caldav_etag,
updated_at = :updated_at
WHERE id = :id AND tenant_id = :tenant_id
`, a)
if err != nil {
return fmt.Errorf("updating appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
return nil
}
func (s *AppointmentService) Delete(ctx context.Context, tenantID, id uuid.UUID) error {
result, err := s.db.ExecContext(ctx, "DELETE FROM appointments WHERE id = $1 AND tenant_id = $2", id, tenantID)
if err != nil {
return fmt.Errorf("deleting appointment: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("appointment not found")
}
return nil
}

View File

@@ -0,0 +1,687 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav"
"github.com/emersion/go-webdav/caldav"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
const (
calDAVDomain = "kanzlai.msbls.de"
calDAVProdID = "-//KanzlAI//KanzlAI-mGMT//EN"
defaultSyncMin = 15
)
// CalDAVConfig holds per-tenant CalDAV configuration from tenants.settings.
type CalDAVConfig struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
CalendarPath string `json:"calendar_path"`
SyncEnabled bool `json:"sync_enabled"`
SyncIntervalMinutes int `json:"sync_interval_minutes"`
}
// SyncStatus holds the last sync result for a tenant.
type SyncStatus struct {
TenantID uuid.UUID `json:"tenant_id"`
LastSyncAt time.Time `json:"last_sync_at"`
ItemsPushed int `json:"items_pushed"`
ItemsPulled int `json:"items_pulled"`
Errors []string `json:"errors,omitempty"`
SyncDuration string `json:"sync_duration"`
}
// CalDAVService handles bidirectional CalDAV synchronization.
type CalDAVService struct {
db *sqlx.DB
mu sync.RWMutex
statuses map[uuid.UUID]*SyncStatus // per-tenant sync status
stopCh chan struct{}
wg sync.WaitGroup
}
// NewCalDAVService creates a new CalDAV sync service.
func NewCalDAVService(db *sqlx.DB) *CalDAVService {
return &CalDAVService{
db: db,
statuses: make(map[uuid.UUID]*SyncStatus),
stopCh: make(chan struct{}),
}
}
// GetStatus returns the last sync status for a tenant.
func (s *CalDAVService) GetStatus(tenantID uuid.UUID) *SyncStatus {
s.mu.RLock()
defer s.mu.RUnlock()
return s.statuses[tenantID]
}
// setStatus stores the sync status for a tenant.
func (s *CalDAVService) setStatus(status *SyncStatus) {
s.mu.Lock()
defer s.mu.Unlock()
s.statuses[status.TenantID] = status
}
// Start begins the background sync goroutine that polls per-tenant.
func (s *CalDAVService) Start() {
s.wg.Go(func() {
s.backgroundLoop()
})
log.Println("CalDAV sync service started")
}
// Stop gracefully stops the background sync.
func (s *CalDAVService) Stop() {
close(s.stopCh)
s.wg.Wait()
log.Println("CalDAV sync service stopped")
}
// backgroundLoop polls tenants at their configured interval.
func (s *CalDAVService) backgroundLoop() {
// Check every minute, but only sync tenants whose interval has elapsed.
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-s.stopCh:
return
case <-ticker.C:
s.syncAllTenants()
}
}
}
// syncAllTenants checks all tenants and syncs those due for a sync.
func (s *CalDAVService) syncAllTenants() {
configs, err := s.loadAllTenantConfigs()
if err != nil {
log.Printf("CalDAV: failed to load tenant configs: %v", err)
return
}
for tenantID, cfg := range configs {
if !cfg.SyncEnabled {
continue
}
interval := cfg.SyncIntervalMinutes
if interval <= 0 {
interval = defaultSyncMin
}
// Check if enough time has passed since last sync
status := s.GetStatus(tenantID)
if status != nil && time.Since(status.LastSyncAt) < time.Duration(interval)*time.Minute {
continue
}
go func(tid uuid.UUID, c CalDAVConfig) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if _, err := s.SyncTenant(ctx, tid, c); err != nil {
log.Printf("CalDAV: sync failed for tenant %s: %v", tid, err)
}
}(tenantID, cfg)
}
}
// loadAllTenantConfigs reads CalDAV configs from all tenants.
func (s *CalDAVService) loadAllTenantConfigs() (map[uuid.UUID]CalDAVConfig, error) {
type row struct {
ID uuid.UUID `db:"id"`
Settings json.RawMessage `db:"settings"`
}
var rows []row
if err := s.db.Select(&rows, "SELECT id, settings FROM tenants"); err != nil {
return nil, fmt.Errorf("querying tenants: %w", err)
}
result := make(map[uuid.UUID]CalDAVConfig)
for _, r := range rows {
cfg, err := parseCalDAVConfig(r.Settings)
if err != nil || cfg.URL == "" {
continue
}
result[r.ID] = cfg
}
return result, nil
}
// LoadTenantConfig reads CalDAV config for a single tenant.
func (s *CalDAVService) LoadTenantConfig(tenantID uuid.UUID) (*CalDAVConfig, error) {
var settings json.RawMessage
if err := s.db.Get(&settings, "SELECT settings FROM tenants WHERE id = $1", tenantID); err != nil {
return nil, fmt.Errorf("loading tenant settings: %w", err)
}
cfg, err := parseCalDAVConfig(settings)
if err != nil {
return nil, err
}
if cfg.URL == "" {
return nil, fmt.Errorf("no CalDAV configuration for tenant")
}
return &cfg, nil
}
func parseCalDAVConfig(settings json.RawMessage) (CalDAVConfig, error) {
if len(settings) == 0 {
return CalDAVConfig{}, nil
}
var wrapper struct {
CalDAV CalDAVConfig `json:"caldav"`
}
if err := json.Unmarshal(settings, &wrapper); err != nil {
return CalDAVConfig{}, fmt.Errorf("parsing CalDAV settings: %w", err)
}
return wrapper.CalDAV, nil
}
// newCalDAVClient creates a caldav.Client from config.
func newCalDAVClient(cfg CalDAVConfig) (*caldav.Client, error) {
httpClient := webdav.HTTPClientWithBasicAuth(nil, cfg.Username, cfg.Password)
return caldav.NewClient(httpClient, cfg.URL)
}
// SyncTenant performs a full bidirectional sync for a tenant.
func (s *CalDAVService) SyncTenant(ctx context.Context, tenantID uuid.UUID, cfg CalDAVConfig) (*SyncStatus, error) {
start := time.Now()
status := &SyncStatus{
TenantID: tenantID,
}
client, err := newCalDAVClient(cfg)
if err != nil {
status.Errors = append(status.Errors, fmt.Sprintf("creating client: %v", err))
status.LastSyncAt = time.Now()
s.setStatus(status)
return status, err
}
// Push local changes to CalDAV
pushed, pushErrs := s.pushAll(ctx, client, tenantID, cfg)
status.ItemsPushed = pushed
status.Errors = append(status.Errors, pushErrs...)
// Pull remote changes from CalDAV
pulled, pullErrs := s.pullAll(ctx, client, tenantID, cfg)
status.ItemsPulled = pulled
status.Errors = append(status.Errors, pullErrs...)
status.LastSyncAt = time.Now()
status.SyncDuration = time.Since(start).String()
s.setStatus(status)
if len(status.Errors) > 0 {
return status, fmt.Errorf("sync completed with %d errors", len(status.Errors))
}
return status, nil
}
// --- Push: Local -> CalDAV ---
// pushAll pushes all deadlines and appointments to CalDAV.
func (s *CalDAVService) pushAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) {
var pushed int
var errs []string
// Push deadlines as VTODO
deadlines, err := s.loadDeadlines(tenantID)
if err != nil {
return 0, []string{fmt.Sprintf("loading deadlines: %v", err)}
}
for _, d := range deadlines {
if err := s.pushDeadline(ctx, client, cfg, &d); err != nil {
errs = append(errs, fmt.Sprintf("push deadline %s: %v", d.ID, err))
} else {
pushed++
}
}
// Push appointments as VEVENT
appointments, err := s.loadAppointments(ctx, tenantID)
if err != nil {
errs = append(errs, fmt.Sprintf("loading appointments: %v", err))
return pushed, errs
}
for _, a := range appointments {
if err := s.pushAppointment(ctx, client, cfg, &a); err != nil {
errs = append(errs, fmt.Sprintf("push appointment %s: %v", a.ID, err))
} else {
pushed++
}
}
return pushed, errs
}
// PushDeadline pushes a single deadline to CalDAV (called on create/update).
func (s *CalDAVService) PushDeadline(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error {
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil // CalDAV not configured or disabled — silently skip
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
return s.pushDeadline(ctx, client, *cfg, deadline)
}
func (s *CalDAVService) pushDeadline(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, d *models.Deadline) error {
uid := deadlineUID(d.ID)
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, calDAVProdID)
cal.Props.SetText(ical.PropVersion, "2.0")
todo := ical.NewComponent(ical.CompToDo)
todo.Props.SetText(ical.PropUID, uid)
todo.Props.SetText(ical.PropSummary, d.Title)
todo.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
if d.Description != nil {
todo.Props.SetText(ical.PropDescription, *d.Description)
}
if d.Notes != nil {
desc := ""
if d.Description != nil {
desc = *d.Description + "\n\n"
}
todo.Props.SetText(ical.PropDescription, desc+*d.Notes)
}
// Parse due_date (stored as string "YYYY-MM-DD")
if due, err := time.Parse("2006-01-02", d.DueDate); err == nil {
todo.Props.SetDate(ical.PropDue, due)
}
// Map status
switch d.Status {
case "completed":
todo.Props.SetText(ical.PropStatus, "COMPLETED")
if d.CompletedAt != nil {
todo.Props.SetDateTime(ical.PropCompleted, d.CompletedAt.UTC())
}
case "pending":
todo.Props.SetText(ical.PropStatus, "NEEDS-ACTION")
default:
todo.Props.SetText(ical.PropStatus, "IN-PROCESS")
}
cal.Children = append(cal.Children, todo)
path := calendarObjectPath(cfg.CalendarPath, uid)
obj, err := client.PutCalendarObject(ctx, path, cal)
if err != nil {
return fmt.Errorf("putting VTODO: %w", err)
}
// Update caldav_uid and etag in DB
return s.updateDeadlineCalDAV(d.ID, uid, obj.ETag)
}
// PushAppointment pushes a single appointment to CalDAV (called on create/update).
func (s *CalDAVService) PushAppointment(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error {
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
return s.pushAppointment(ctx, client, *cfg, appointment)
}
func (s *CalDAVService) pushAppointment(ctx context.Context, client *caldav.Client, cfg CalDAVConfig, a *models.Appointment) error {
uid := appointmentUID(a.ID)
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, calDAVProdID)
cal.Props.SetText(ical.PropVersion, "2.0")
event := ical.NewEvent()
event.Props.SetText(ical.PropUID, uid)
event.Props.SetText(ical.PropSummary, a.Title)
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
event.Props.SetDateTime(ical.PropDateTimeStart, a.StartAt.UTC())
if a.EndAt != nil {
event.Props.SetDateTime(ical.PropDateTimeEnd, a.EndAt.UTC())
}
if a.Description != nil {
event.Props.SetText(ical.PropDescription, *a.Description)
}
if a.Location != nil {
event.Props.SetText(ical.PropLocation, *a.Location)
}
cal.Children = append(cal.Children, event.Component)
path := calendarObjectPath(cfg.CalendarPath, uid)
obj, err := client.PutCalendarObject(ctx, path, cal)
if err != nil {
return fmt.Errorf("putting VEVENT: %w", err)
}
return s.updateAppointmentCalDAV(a.ID, uid, obj.ETag)
}
// DeleteDeadlineCalDAV removes a deadline's VTODO from CalDAV.
func (s *CalDAVService) DeleteDeadlineCalDAV(ctx context.Context, tenantID uuid.UUID, deadline *models.Deadline) error {
if deadline.CalDAVUID == nil || *deadline.CalDAVUID == "" {
return nil
}
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
path := calendarObjectPath(cfg.CalendarPath, *deadline.CalDAVUID)
return client.RemoveAll(ctx, path)
}
// DeleteAppointmentCalDAV removes an appointment's VEVENT from CalDAV.
func (s *CalDAVService) DeleteAppointmentCalDAV(ctx context.Context, tenantID uuid.UUID, appointment *models.Appointment) error {
if appointment.CalDAVUID == nil || *appointment.CalDAVUID == "" {
return nil
}
cfg, err := s.LoadTenantConfig(tenantID)
if err != nil || !cfg.SyncEnabled {
return nil
}
client, err := newCalDAVClient(*cfg)
if err != nil {
return fmt.Errorf("creating CalDAV client: %w", err)
}
path := calendarObjectPath(cfg.CalendarPath, *appointment.CalDAVUID)
return client.RemoveAll(ctx, path)
}
// --- Pull: CalDAV -> Local ---
// pullAll fetches all calendar objects from CalDAV and reconciles with local DB.
func (s *CalDAVService) pullAll(ctx context.Context, client *caldav.Client, tenantID uuid.UUID, cfg CalDAVConfig) (int, []string) {
var pulled int
var errs []string
query := &caldav.CalendarQuery{
CompFilter: caldav.CompFilter{
Name: ical.CompCalendar,
},
}
objects, err := client.QueryCalendar(ctx, cfg.CalendarPath, query)
if err != nil {
return 0, []string{fmt.Sprintf("querying calendar: %v", err)}
}
for _, obj := range objects {
if obj.Data == nil {
continue
}
for _, child := range obj.Data.Children {
switch child.Name {
case ical.CompToDo:
uid, _ := child.Props.Text(ical.PropUID)
if uid == "" || !isKanzlAIUID(uid, "deadline") {
continue
}
if err := s.reconcileDeadline(ctx, tenantID, child, obj.ETag); err != nil {
errs = append(errs, fmt.Sprintf("reconcile deadline %s: %v", uid, err))
} else {
pulled++
}
case ical.CompEvent:
uid, _ := child.Props.Text(ical.PropUID)
if uid == "" || !isKanzlAIUID(uid, "appointment") {
continue
}
if err := s.reconcileAppointment(ctx, tenantID, child, obj.ETag); err != nil {
errs = append(errs, fmt.Sprintf("reconcile appointment %s: %v", uid, err))
} else {
pulled++
}
}
}
}
return pulled, errs
}
// reconcileDeadline handles conflict resolution for a pulled VTODO.
// KanzlAI wins for dates/status, CalDAV wins for notes/description.
func (s *CalDAVService) reconcileDeadline(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error {
uid, _ := comp.Props.Text(ical.PropUID)
deadlineID := extractIDFromUID(uid, "deadline")
if deadlineID == uuid.Nil {
return fmt.Errorf("invalid UID: %s", uid)
}
// Load existing deadline
var d models.Deadline
err := s.db.Get(&d, `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines WHERE id = $1 AND tenant_id = $2`, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("loading deadline: %w", err)
}
// Check if remote changed (etag mismatch)
if d.CalDAVEtag != nil && *d.CalDAVEtag == remoteEtag {
return nil // No change
}
// CalDAV wins for description/notes
description, _ := comp.Props.Text(ical.PropDescription)
hasConflict := false
if description != "" {
existingDesc := ""
if d.Description != nil {
existingDesc = *d.Description
}
existingNotes := ""
if d.Notes != nil {
existingNotes = *d.Notes
}
// CalDAV wins for notes/description
if description != existingDesc && description != existingNotes {
hasConflict = true
_, err = s.db.Exec(`UPDATE deadlines SET notes = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4`, description, remoteEtag, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("updating deadline notes: %w", err)
}
}
}
if !hasConflict {
// Just update etag
_, err = s.db.Exec(`UPDATE deadlines SET caldav_etag = $1, updated_at = NOW()
WHERE id = $2 AND tenant_id = $3`, remoteEtag, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("updating deadline etag: %w", err)
}
}
// Log conflict in case_events if detected
if hasConflict {
s.logConflictEvent(ctx, tenantID, d.CaseID, "deadline", deadlineID, "CalDAV description updated from remote")
}
return nil
}
// reconcileAppointment handles conflict resolution for a pulled VEVENT.
func (s *CalDAVService) reconcileAppointment(ctx context.Context, tenantID uuid.UUID, comp *ical.Component, remoteEtag string) error {
uid, _ := comp.Props.Text(ical.PropUID)
appointmentID := extractIDFromUID(uid, "appointment")
if appointmentID == uuid.Nil {
return fmt.Errorf("invalid UID: %s", uid)
}
var a models.Appointment
err := s.db.GetContext(ctx, &a, `SELECT * FROM appointments WHERE id = $1 AND tenant_id = $2`, appointmentID, tenantID)
if err != nil {
return fmt.Errorf("loading appointment: %w", err)
}
if a.CalDAVEtag != nil && *a.CalDAVEtag == remoteEtag {
return nil
}
// CalDAV wins for description
description, _ := comp.Props.Text(ical.PropDescription)
location, _ := comp.Props.Text(ical.PropLocation)
hasConflict := false
updates := []string{"caldav_etag = $1", "updated_at = NOW()"}
args := []any{remoteEtag}
argN := 2
if description != "" {
existingDesc := ""
if a.Description != nil {
existingDesc = *a.Description
}
if description != existingDesc {
hasConflict = true
updates = append(updates, fmt.Sprintf("description = $%d", argN))
args = append(args, description)
argN++
}
}
if location != "" {
existingLoc := ""
if a.Location != nil {
existingLoc = *a.Location
}
if location != existingLoc {
hasConflict = true
updates = append(updates, fmt.Sprintf("location = $%d", argN))
args = append(args, location)
argN++
}
}
args = append(args, appointmentID, tenantID)
query := fmt.Sprintf("UPDATE appointments SET %s WHERE id = $%d AND tenant_id = $%d",
strings.Join(updates, ", "), argN, argN+1)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return fmt.Errorf("updating appointment: %w", err)
}
if hasConflict {
caseID := uuid.Nil
if a.CaseID != nil {
caseID = *a.CaseID
}
s.logConflictEvent(ctx, tenantID, caseID, "appointment", appointmentID, "CalDAV description/location updated from remote")
}
return nil
}
// --- DB helpers ---
func (s *CalDAVService) loadDeadlines(tenantID uuid.UUID) ([]models.Deadline, error) {
var deadlines []models.Deadline
err := s.db.Select(&deadlines, `SELECT id, tenant_id, case_id, title, description, due_date,
original_due_date, warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines WHERE tenant_id = $1`, tenantID)
return deadlines, err
}
func (s *CalDAVService) loadAppointments(ctx context.Context, tenantID uuid.UUID) ([]models.Appointment, error) {
var appointments []models.Appointment
err := s.db.SelectContext(ctx, &appointments, "SELECT * FROM appointments WHERE tenant_id = $1", tenantID)
return appointments, err
}
func (s *CalDAVService) updateDeadlineCalDAV(id uuid.UUID, calDAVUID, etag string) error {
_, err := s.db.Exec(`UPDATE deadlines SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, calDAVUID, etag, id)
return err
}
func (s *CalDAVService) updateAppointmentCalDAV(id uuid.UUID, calDAVUID, etag string) error {
_, err := s.db.Exec(`UPDATE appointments SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, calDAVUID, etag, id)
return err
}
func (s *CalDAVService) logConflictEvent(ctx context.Context, tenantID, caseID uuid.UUID, objectType string, objectID uuid.UUID, msg string) {
if caseID == uuid.Nil {
return
}
metadata, _ := json.Marshal(map[string]string{
"object_type": objectType,
"object_id": objectID.String(),
"source": "caldav_sync",
})
_, err := s.db.ExecContext(ctx, `INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, metadata, created_at, updated_at)
VALUES ($1, $2, $3, 'caldav_conflict', $4, $5, $6, NOW(), NOW())`,
uuid.New(), tenantID, caseID, "CalDAV sync conflict", msg, metadata)
if err != nil {
log.Printf("CalDAV: failed to log conflict event: %v", err)
}
}
// --- UID helpers ---
func deadlineUID(id uuid.UUID) string {
return fmt.Sprintf("kanzlai-deadline-%s@%s", id, calDAVDomain)
}
func appointmentUID(id uuid.UUID) string {
return fmt.Sprintf("kanzlai-appointment-%s@%s", id, calDAVDomain)
}
func isKanzlAIUID(uid, objectType string) bool {
return strings.HasPrefix(uid, "kanzlai-"+objectType+"-") && strings.HasSuffix(uid, "@"+calDAVDomain)
}
func extractIDFromUID(uid, objectType string) uuid.UUID {
prefix := "kanzlai-" + objectType + "-"
suffix := "@" + calDAVDomain
if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) {
return uuid.Nil
}
idStr := uid[len(prefix) : len(uid)-len(suffix)]
id, err := uuid.Parse(idStr)
if err != nil {
return uuid.Nil
}
return id
}
func calendarObjectPath(calendarPath, uid string) string {
path := strings.TrimSuffix(calendarPath, "/")
return path + "/" + uid + ".ics"
}

View File

@@ -0,0 +1,124 @@
package services
import (
"testing"
"github.com/google/uuid"
)
func TestDeadlineUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
uid := deadlineUID(id)
want := "kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de"
if uid != want {
t.Errorf("deadlineUID = %q, want %q", uid, want)
}
}
func TestAppointmentUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
uid := appointmentUID(id)
want := "kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de"
if uid != want {
t.Errorf("appointmentUID = %q, want %q", uid, want)
}
}
func TestIsKanzlAIUID(t *testing.T) {
tests := []struct {
uid string
objectType string
want bool
}{
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "deadline", true},
{"kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", true},
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", false},
{"random-uid@other.com", "deadline", false},
{"", "deadline", false},
}
for _, tt := range tests {
got := isKanzlAIUID(tt.uid, tt.objectType)
if got != tt.want {
t.Errorf("isKanzlAIUID(%q, %q) = %v, want %v", tt.uid, tt.objectType, got, tt.want)
}
}
}
func TestExtractIDFromUID(t *testing.T) {
id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
tests := []struct {
uid string
objectType string
want uuid.UUID
}{
{"kanzlai-deadline-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "deadline", id},
{"kanzlai-appointment-550e8400-e29b-41d4-a716-446655440000@kanzlai.msbls.de", "appointment", id},
{"invalid-uid", "deadline", uuid.Nil},
{"kanzlai-deadline-not-a-uuid@kanzlai.msbls.de", "deadline", uuid.Nil},
}
for _, tt := range tests {
got := extractIDFromUID(tt.uid, tt.objectType)
if got != tt.want {
t.Errorf("extractIDFromUID(%q, %q) = %v, want %v", tt.uid, tt.objectType, got, tt.want)
}
}
}
func TestCalendarObjectPath(t *testing.T) {
tests := []struct {
calendarPath string
uid string
want string
}{
{"/dav/calendars/user/cal", "kanzlai-deadline-abc@kanzlai.msbls.de", "/dav/calendars/user/cal/kanzlai-deadline-abc@kanzlai.msbls.de.ics"},
{"/dav/calendars/user/cal/", "kanzlai-deadline-abc@kanzlai.msbls.de", "/dav/calendars/user/cal/kanzlai-deadline-abc@kanzlai.msbls.de.ics"},
}
for _, tt := range tests {
got := calendarObjectPath(tt.calendarPath, tt.uid)
if got != tt.want {
t.Errorf("calendarObjectPath(%q, %q) = %q, want %q", tt.calendarPath, tt.uid, got, tt.want)
}
}
}
func TestParseCalDAVConfig(t *testing.T) {
settings := []byte(`{"caldav": {"url": "https://dav.example.com", "username": "user", "password": "pass", "calendar_path": "/cal", "sync_enabled": true, "sync_interval_minutes": 30}}`)
cfg, err := parseCalDAVConfig(settings)
if err != nil {
t.Fatalf("parseCalDAVConfig: %v", err)
}
if cfg.URL != "https://dav.example.com" {
t.Errorf("URL = %q, want %q", cfg.URL, "https://dav.example.com")
}
if cfg.Username != "user" {
t.Errorf("Username = %q, want %q", cfg.Username, "user")
}
if cfg.SyncIntervalMinutes != 30 {
t.Errorf("SyncIntervalMinutes = %d, want 30", cfg.SyncIntervalMinutes)
}
if !cfg.SyncEnabled {
t.Error("SyncEnabled = false, want true")
}
}
func TestParseCalDAVConfig_Empty(t *testing.T) {
cfg, err := parseCalDAVConfig(nil)
if err != nil {
t.Fatalf("parseCalDAVConfig(nil): %v", err)
}
if cfg.URL != "" {
t.Errorf("expected empty config, got URL=%q", cfg.URL)
}
}
func TestParseCalDAVConfig_NoCalDAV(t *testing.T) {
settings := []byte(`{"other_setting": true}`)
cfg, err := parseCalDAVConfig(settings)
if err != nil {
t.Fatalf("parseCalDAVConfig: %v", err)
}
if cfg.URL != "" {
t.Errorf("expected empty caldav config, got URL=%q", cfg.URL)
}
}

View File

@@ -0,0 +1,151 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
type DashboardService struct {
db *sqlx.DB
}
func NewDashboardService(db *sqlx.DB) *DashboardService {
return &DashboardService{db: db}
}
type DashboardData struct {
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
CaseSummary CaseSummary `json:"case_summary"`
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
RecentActivity []RecentActivity `json:"recent_activity"`
}
type DeadlineSummary struct {
OverdueCount int `json:"overdue_count" db:"overdue_count"`
DueThisWeek int `json:"due_this_week" db:"due_this_week"`
DueNextWeek int `json:"due_next_week" db:"due_next_week"`
OKCount int `json:"ok_count" db:"ok_count"`
}
type CaseSummary struct {
ActiveCount int `json:"active_count" db:"active_count"`
NewThisMonth int `json:"new_this_month" db:"new_this_month"`
ClosedCount int `json:"closed_count" db:"closed_count"`
}
type UpcomingDeadline struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
DueDate string `json:"due_date" db:"due_date"`
CaseNumber string `json:"case_number" db:"case_number"`
CaseTitle string `json:"case_title" db:"case_title"`
Status string `json:"status" db:"status"`
}
type UpcomingAppointment struct {
ID uuid.UUID `json:"id" db:"id"`
Title string `json:"title" db:"title"`
StartAt time.Time `json:"start_at" db:"start_at"`
CaseNumber *string `json:"case_number" db:"case_number"`
Location *string `json:"location" db:"location"`
}
type RecentActivity struct {
EventType *string `json:"event_type" db:"event_type"`
Title string `json:"title" db:"title"`
CaseNumber string `json:"case_number" db:"case_number"`
EventDate *time.Time `json:"event_date" db:"event_date"`
}
func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*DashboardData, error) {
now := time.Now()
today := now.Format("2006-01-02")
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
data := &DashboardData{}
// Single query with CTEs for deadline + case summaries
summaryQuery := `
WITH deadline_stats AS (
SELECT
COUNT(*) FILTER (WHERE due_date < $2 AND status = 'pending') AS overdue_count,
COUNT(*) FILTER (WHERE due_date >= $2 AND due_date <= $3 AND status = 'pending') AS due_this_week,
COUNT(*) FILTER (WHERE due_date > $3 AND due_date <= $4 AND status = 'pending') AS due_next_week,
COUNT(*) FILTER (WHERE due_date > $4 AND status = 'pending') AS ok_count
FROM deadlines
WHERE tenant_id = $1
),
case_stats AS (
SELECT
COUNT(*) FILTER (WHERE status = 'active') AS active_count,
COUNT(*) FILTER (WHERE created_at >= $5::date AND status != 'archived') AS new_this_month,
COUNT(*) FILTER (WHERE status IN ('closed', 'archived')) AS closed_count
FROM cases
WHERE tenant_id = $1
)
SELECT
ds.overdue_count, ds.due_this_week, ds.due_next_week, ds.ok_count,
cs.active_count, cs.new_this_month, cs.closed_count
FROM deadline_stats ds, case_stats cs`
var summaryRow struct {
DeadlineSummary
CaseSummary
}
err := s.db.GetContext(ctx, &summaryRow, summaryQuery, tenantID, today, endOfWeek, endOfNextWeek, startOfMonth)
if err != nil {
return nil, fmt.Errorf("dashboard summary: %w", err)
}
data.DeadlineSummary = summaryRow.DeadlineSummary
data.CaseSummary = summaryRow.CaseSummary
// Upcoming deadlines (next 7 days)
deadlineQuery := `
SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status
FROM deadlines d
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
ORDER BY d.due_date ASC`
data.UpcomingDeadlines = []UpcomingDeadline{}
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, deadlineQuery, tenantID, today, in7Days); err != nil {
return nil, fmt.Errorf("dashboard upcoming deadlines: %w", err)
}
// Upcoming appointments (next 7 days)
appointmentQuery := `
SELECT a.id, a.title, a.start_at, c.case_number, a.location
FROM appointments a
LEFT JOIN cases c ON c.id = a.case_id AND c.tenant_id = a.tenant_id
WHERE a.tenant_id = $1 AND a.start_at >= $2::timestamp AND a.start_at < ($2::date + interval '7 days')
ORDER BY a.start_at ASC`
data.UpcomingAppointments = []UpcomingAppointment{}
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, appointmentQuery, tenantID, now); err != nil {
return nil, fmt.Errorf("dashboard upcoming appointments: %w", err)
}
// Recent activity (last 10 case events)
activityQuery := `
SELECT ce.event_type, ce.title, c.case_number, ce.event_date
FROM case_events ce
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
WHERE ce.tenant_id = $1
ORDER BY COALESCE(ce.event_date, ce.created_at) DESC
LIMIT 10`
data.RecentActivity = []RecentActivity{}
if err := s.db.SelectContext(ctx, &data.RecentActivity, activityQuery, tenantID); err != nil {
return nil, fmt.Errorf("dashboard recent activity: %w", err)
}
return data, nil
}

View File

@@ -0,0 +1,33 @@
package services
import (
"testing"
"time"
)
func TestDashboardDateCalculations(t *testing.T) {
// Verify the date range logic used in Get()
now := time.Date(2026, 3, 25, 14, 0, 0, 0, time.UTC) // Wednesday
today := now.Format("2006-01-02")
endOfWeek := now.AddDate(0, 0, 7-int(now.Weekday())).Format("2006-01-02")
endOfNextWeek := now.AddDate(0, 0, 14-int(now.Weekday())).Format("2006-01-02")
in7Days := now.AddDate(0, 0, 7).Format("2006-01-02")
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format("2006-01-02")
if today != "2026-03-25" {
t.Errorf("today = %s, want 2026-03-25", today)
}
if endOfWeek != "2026-03-29" { // Sunday
t.Errorf("endOfWeek = %s, want 2026-03-29", endOfWeek)
}
if endOfNextWeek != "2026-04-05" {
t.Errorf("endOfNextWeek = %s, want 2026-04-05", endOfNextWeek)
}
if in7Days != "2026-04-01" {
t.Errorf("in7Days = %s, want 2026-04-01", in7Days)
}
if startOfMonth != "2026-03-01" {
t.Errorf("startOfMonth = %s, want 2026-03-01", startOfMonth)
}
}

View File

@@ -0,0 +1,99 @@
package services
import (
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// CalculatedDeadline holds a calculated deadline with adjustment info
type CalculatedDeadline struct {
RuleCode string `json:"rule_code"`
RuleID string `json:"rule_id"`
Title string `json:"title"`
DueDate string `json:"due_date"`
OriginalDueDate string `json:"original_due_date"`
WasAdjusted bool `json:"was_adjusted"`
}
// DeadlineCalculator calculates deadlines from rules and event dates
type DeadlineCalculator struct {
holidays *HolidayService
}
// NewDeadlineCalculator creates a new calculator
func NewDeadlineCalculator(holidays *HolidayService) *DeadlineCalculator {
return &DeadlineCalculator{holidays: holidays}
}
// CalculateEndDate calculates the end date for a single deadline rule based on an event date.
// Adapted from youpc.org CalculateDeadlineEndDate.
func (c *DeadlineCalculator) CalculateEndDate(eventDate time.Time, rule models.DeadlineRule) (adjusted time.Time, original time.Time, wasAdjusted bool) {
endDate := eventDate
timing := "after"
if rule.Timing != nil {
timing = *rule.Timing
}
durationValue := rule.DurationValue
durationUnit := rule.DurationUnit
if timing == "before" {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, -durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, -durationValue*7)
case "months":
endDate = endDate.AddDate(0, -durationValue, 0)
}
} else {
switch durationUnit {
case "days":
endDate = endDate.AddDate(0, 0, durationValue)
case "weeks":
endDate = endDate.AddDate(0, 0, durationValue*7)
case "months":
endDate = endDate.AddDate(0, durationValue, 0)
}
}
original = endDate
adjusted, _, wasAdjusted = c.holidays.AdjustForNonWorkingDays(endDate)
return adjusted, original, wasAdjusted
}
// CalculateFromRules calculates deadlines for a set of rules given an event date.
// Returns a list of calculated deadlines with due dates.
func (c *DeadlineCalculator) CalculateFromRules(eventDate time.Time, rules []models.DeadlineRule) []CalculatedDeadline {
results := make([]CalculatedDeadline, 0, len(rules))
for _, rule := range rules {
var adjusted, original time.Time
var wasAdjusted bool
if rule.DurationValue > 0 {
adjusted, original, wasAdjusted = c.CalculateEndDate(eventDate, rule)
} else {
adjusted = eventDate
original = eventDate
}
code := ""
if rule.Code != nil {
code = *rule.Code
}
results = append(results, CalculatedDeadline{
RuleCode: code,
RuleID: rule.ID.String(),
Title: rule.Name,
DueDate: adjusted.Format("2006-01-02"),
OriginalDueDate: original.Format("2006-01-02"),
WasAdjusted: wasAdjusted,
})
}
return results
}

View File

@@ -0,0 +1,141 @@
package services
import (
"testing"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
func TestCalculateEndDateAfterDays(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 10 days",
DurationValue: 10,
DurationUnit: "days",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 25 March + 10 days = 4 April 2026 (Saturday)
// Apr 5 = Easter Sunday (holiday), Apr 6 = Easter Monday (holiday) -> adjusted to 7 April (Tuesday)
expectedOriginal := time.Date(2026, 4, 4, 0, 0, 0, 0, time.UTC)
expectedAdjusted := time.Date(2026, 4, 7, 0, 0, 0, 0, time.UTC)
if original != expectedOriginal {
t.Errorf("original should be %s, got %s", expectedOriginal, original)
}
if adjusted != expectedAdjusted {
t.Errorf("adjusted should be %s, got %s", expectedAdjusted, adjusted)
}
if !wasAdjusted {
t.Error("should have been adjusted (Saturday)")
}
}
func TestCalculateEndDateBeforeMonths(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) // Monday
timing := "before"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 months before",
DurationValue: 2,
DurationUnit: "months",
Timing: &timing,
}
adjusted, original, wasAdjusted := calc.CalculateEndDate(eventDate, rule)
// 15 June - 2 months = 15 April 2026 (Wednesday)
expected := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s (not a holiday/weekend), got %s", expected, adjusted)
}
if wasAdjusted {
t.Error("should not have been adjusted (Wednesday)")
}
}
func TestCalculateEndDateWeeks(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC) // Wednesday
timing := "after"
rule := models.DeadlineRule{
ID: uuid.New(),
Name: "Test 2 weeks",
DurationValue: 2,
DurationUnit: "weeks",
Timing: &timing,
}
adjusted, original, _ := calc.CalculateEndDate(eventDate, rule)
// 25 March + 14 days = 8 April 2026 (Wednesday)
expected := time.Date(2026, 4, 8, 0, 0, 0, 0, time.UTC)
if original != expected {
t.Errorf("original should be %s, got %s", expected, original)
}
if adjusted != expected {
t.Errorf("adjusted should be %s, got %s", expected, adjusted)
}
}
func TestCalculateFromRules(t *testing.T) {
holidays := NewHolidayService(nil)
calc := NewDeadlineCalculator(holidays)
eventDate := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
timing := "after"
code := "TEST-1"
rules := []models.DeadlineRule{
{
ID: uuid.New(),
Code: &code,
Name: "Rule A",
DurationValue: 7,
DurationUnit: "days",
Timing: &timing,
},
{
ID: uuid.New(),
Name: "Rule B (zero duration)",
DurationValue: 0,
DurationUnit: "days",
},
}
results := calc.CalculateFromRules(eventDate, rules)
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
// Rule A: 25 March + 7 = 1 April (Wednesday)
if results[0].DueDate != "2026-04-01" {
t.Errorf("Rule A due date should be 2026-04-01, got %s", results[0].DueDate)
}
if results[0].RuleCode != "TEST-1" {
t.Errorf("Rule A code should be TEST-1, got %s", results[0].RuleCode)
}
// Rule B: zero duration -> event date
if results[1].DueDate != "2026-03-25" {
t.Errorf("Rule B due date should be 2026-03-25, got %s", results[1].DueDate)
}
}

View File

@@ -0,0 +1,175 @@
package services
import (
"fmt"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineRuleService handles deadline rule queries
type DeadlineRuleService struct {
db *sqlx.DB
}
// NewDeadlineRuleService creates a new deadline rule service
func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
return &DeadlineRuleService{db: db}
}
// List returns deadline rules, optionally filtered by proceeding type
func (s *DeadlineRuleService) List(proceedingTypeID *int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
var err error
if proceedingTypeID != nil {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, *proceedingTypeID)
} else {
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE is_active = true
ORDER BY proceeding_type_id, sequence_order`)
}
if err != nil {
return nil, fmt.Errorf("listing deadline rules: %w", err)
}
return rules, nil
}
// RuleTreeNode represents a deadline rule with its children
type RuleTreeNode struct {
models.DeadlineRule
Children []RuleTreeNode `json:"children,omitempty"`
}
// GetRuleTree returns a hierarchical tree of rules for a proceeding type
func (s *DeadlineRuleService) GetRuleTree(proceedingTypeCode string) ([]RuleTreeNode, error) {
// First resolve proceeding type code to ID
var pt models.ProceedingType
err := s.db.Get(&pt,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE code = $1 AND is_active = true`, proceedingTypeCode)
if err != nil {
return nil, fmt.Errorf("resolving proceeding type %q: %w", proceedingTypeCode, err)
}
// Get all rules for this proceeding type
var rules []models.DeadlineRule
err = s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, pt.ID)
if err != nil {
return nil, fmt.Errorf("listing rules for type %q: %w", proceedingTypeCode, err)
}
return buildTree(rules), nil
}
// GetByIDs returns deadline rules by their IDs
func (s *DeadlineRuleService) GetByIDs(ids []string) ([]models.DeadlineRule, error) {
if len(ids) == 0 {
return nil, nil
}
query, args, err := sqlx.In(
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE id IN (?) AND is_active = true
ORDER BY sequence_order`, ids)
if err != nil {
return nil, fmt.Errorf("building IN query: %w", err)
}
query = s.db.Rebind(query)
var rules []models.DeadlineRule
err = s.db.Select(&rules, query, args...)
if err != nil {
return nil, fmt.Errorf("fetching rules by IDs: %w", err)
}
return rules, nil
}
// GetRulesForProceedingType returns all active rules for a proceeding type ID
func (s *DeadlineRuleService) GetRulesForProceedingType(proceedingTypeID int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
err := s.db.Select(&rules,
`SELECT id, proceeding_type_id, parent_id, code, name, description,
primary_party, event_type, is_mandatory, duration_value, duration_unit,
timing, rule_code, deadline_notes, sequence_order, condition_rule_id,
alt_duration_value, alt_duration_unit, alt_rule_code, is_active,
created_at, updated_at
FROM deadline_rules
WHERE proceeding_type_id = $1 AND is_active = true
ORDER BY sequence_order`, proceedingTypeID)
if err != nil {
return nil, fmt.Errorf("listing rules for proceeding type %d: %w", proceedingTypeID, err)
}
return rules, nil
}
// ListProceedingTypes returns all active proceeding types
func (s *DeadlineRuleService) ListProceedingTypes() ([]models.ProceedingType, error) {
var types []models.ProceedingType
err := s.db.Select(&types,
`SELECT id, code, name, description, jurisdiction, default_color, sort_order, is_active
FROM proceeding_types
WHERE is_active = true
ORDER BY sort_order`)
if err != nil {
return nil, fmt.Errorf("listing proceeding types: %w", err)
}
return types, nil
}
// buildTree converts a flat list of rules into a hierarchical tree
func buildTree(rules []models.DeadlineRule) []RuleTreeNode {
nodeMap := make(map[string]*RuleTreeNode, len(rules))
var roots []RuleTreeNode
// Create nodes
for _, r := range rules {
node := RuleTreeNode{DeadlineRule: r}
nodeMap[r.ID.String()] = &node
}
// Build tree
for _, r := range rules {
node := nodeMap[r.ID.String()]
if r.ParentID != nil {
parentKey := r.ParentID.String()
if parent, ok := nodeMap[parentKey]; ok {
parent.Children = append(parent.Children, *node)
continue
}
}
roots = append(roots, *node)
}
return roots
}

View File

@@ -0,0 +1,197 @@
package services
import (
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
// DeadlineService handles CRUD operations for case deadlines
type DeadlineService struct {
db *sqlx.DB
}
// NewDeadlineService creates a new deadline service
func NewDeadlineService(db *sqlx.DB) *DeadlineService {
return &DeadlineService{db: db}
}
// ListAll returns all deadlines for a tenant, ordered by due_date
func (s *DeadlineService) ListAll(tenantID uuid.UUID) ([]models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1
ORDER BY due_date ASC`
var deadlines []models.Deadline
err := s.db.Select(&deadlines, query, tenantID)
if err != nil {
return nil, fmt.Errorf("listing all deadlines: %w", err)
}
return deadlines, nil
}
// ListForCase returns all deadlines for a case, scoped to tenant
func (s *DeadlineService) ListForCase(tenantID, caseID uuid.UUID) ([]models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND case_id = $2
ORDER BY due_date ASC`
var deadlines []models.Deadline
err := s.db.Select(&deadlines, query, tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("listing deadlines for case: %w", err)
}
return deadlines, nil
}
// GetByID returns a single deadline by ID, scoped to tenant
func (s *DeadlineService) GetByID(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `SELECT id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at
FROM deadlines
WHERE tenant_id = $1 AND id = $2`
var d models.Deadline
err := s.db.Get(&d, query, tenantID, deadlineID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting deadline: %w", err)
}
return &d, nil
}
// CreateDeadlineInput holds the fields for creating a deadline
type CreateDeadlineInput struct {
CaseID uuid.UUID `json:"case_id"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"`
WarningDate *string `json:"warning_date,omitempty"`
Source string `json:"source"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// Create inserts a new deadline
func (s *DeadlineService) Create(tenantID uuid.UUID, input CreateDeadlineInput) (*models.Deadline, error) {
id := uuid.New()
source := input.Source
if source == "" {
source = "manual"
}
query := `INSERT INTO deadlines (id, tenant_id, case_id, title, description, due_date,
warning_date, source, rule_id, status, notes,
created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, NOW(), NOW())
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, id, tenantID, input.CaseID, input.Title, input.Description,
input.DueDate, input.WarningDate, source, input.RuleID, input.Notes)
if err != nil {
return nil, fmt.Errorf("creating deadline: %w", err)
}
return &d, nil
}
// UpdateDeadlineInput holds the fields for updating a deadline
type UpdateDeadlineInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"`
WarningDate *string `json:"warning_date,omitempty"`
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
}
// Update modifies an existing deadline
func (s *DeadlineService) Update(tenantID, deadlineID uuid.UUID, input UpdateDeadlineInput) (*models.Deadline, error) {
// First check it exists and belongs to tenant
existing, err := s.GetByID(tenantID, deadlineID)
if err != nil {
return nil, err
}
if existing == nil {
return nil, nil
}
query := `UPDATE deadlines SET
title = COALESCE($1, title),
description = COALESCE($2, description),
due_date = COALESCE($3, due_date),
warning_date = COALESCE($4, warning_date),
notes = COALESCE($5, notes),
status = COALESCE($6, status),
rule_id = COALESCE($7, rule_id),
updated_at = NOW()
WHERE id = $8 AND tenant_id = $9
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err = s.db.Get(&d, query, input.Title, input.Description, input.DueDate,
input.WarningDate, input.Notes, input.Status, input.RuleID,
deadlineID, tenantID)
if err != nil {
return nil, fmt.Errorf("updating deadline: %w", err)
}
return &d, nil
}
// Complete marks a deadline as completed
func (s *DeadlineService) Complete(tenantID, deadlineID uuid.UUID) (*models.Deadline, error) {
query := `UPDATE deadlines SET
status = 'completed',
completed_at = $1,
updated_at = NOW()
WHERE id = $2 AND tenant_id = $3
RETURNING id, tenant_id, case_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at,
caldav_uid, caldav_etag, notes, created_at, updated_at`
var d models.Deadline
err := s.db.Get(&d, query, time.Now(), deadlineID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("completing deadline: %w", err)
}
return &d, nil
}
// Delete removes a deadline
func (s *DeadlineService) Delete(tenantID, deadlineID uuid.UUID) error {
query := `DELETE FROM deadlines WHERE id = $1 AND tenant_id = $2`
result, err := s.db.Exec(query, deadlineID, tenantID)
if err != nil {
return fmt.Errorf("deleting deadline: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("checking delete result: %w", err)
}
if rows == 0 {
return fmt.Errorf("deadline not found")
}
return nil
}

View File

@@ -0,0 +1,163 @@
package services
import (
"context"
"database/sql"
"fmt"
"io"
"time"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
const documentBucket = "kanzlai-documents"
type DocumentService struct {
db *sqlx.DB
storage *StorageClient
}
func NewDocumentService(db *sqlx.DB, storage *StorageClient) *DocumentService {
return &DocumentService{db: db, storage: storage}
}
type CreateDocumentInput struct {
Title string `json:"title"`
DocType string `json:"doc_type"`
Filename string
ContentType string
Size int
Data io.Reader
}
func (s *DocumentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.Document, error) {
var docs []models.Document
err := s.db.SelectContext(ctx, &docs,
"SELECT * FROM documents WHERE tenant_id = $1 AND case_id = $2 ORDER BY created_at DESC",
tenantID, caseID)
if err != nil {
return nil, fmt.Errorf("listing documents: %w", err)
}
return docs, nil
}
func (s *DocumentService) GetByID(ctx context.Context, tenantID, docID uuid.UUID) (*models.Document, error) {
var doc models.Document
err := s.db.GetContext(ctx, &doc,
"SELECT * FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("getting document: %w", err)
}
return &doc, nil
}
func (s *DocumentService) Create(ctx context.Context, tenantID, caseID, userID uuid.UUID, input CreateDocumentInput) (*models.Document, error) {
// Verify case belongs to tenant
var caseExists int
if err := s.db.GetContext(ctx, &caseExists,
"SELECT COUNT(*) FROM cases WHERE id = $1 AND tenant_id = $2",
caseID, tenantID); err != nil {
return nil, fmt.Errorf("verifying case: %w", err)
}
if caseExists == 0 {
return nil, fmt.Errorf("case not found")
}
id := uuid.New()
storagePath := fmt.Sprintf("%s/%s/%s_%s", tenantID, caseID, id, input.Filename)
// Upload to Supabase Storage
if err := s.storage.Upload(ctx, documentBucket, storagePath, input.ContentType, input.Data); err != nil {
return nil, fmt.Errorf("uploading file: %w", err)
}
// Insert metadata record
now := time.Now()
_, err := s.db.ExecContext(ctx,
`INSERT INTO documents (id, tenant_id, case_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
id, tenantID, caseID, input.Title, nilIfEmpty(input.DocType), storagePath, input.Size, input.ContentType, userID, now)
if err != nil {
// Best effort: clean up uploaded file
_ = s.storage.Delete(ctx, documentBucket, []string{storagePath})
return nil, fmt.Errorf("inserting document record: %w", err)
}
// Log case event
createEvent(ctx, s.db, tenantID, caseID, userID, "document_uploaded",
fmt.Sprintf("Document uploaded: %s", input.Title), nil)
var doc models.Document
if err := s.db.GetContext(ctx, &doc, "SELECT * FROM documents WHERE id = $1", id); err != nil {
return nil, fmt.Errorf("fetching created document: %w", err)
}
return &doc, nil
}
func (s *DocumentService) Download(ctx context.Context, tenantID, docID uuid.UUID) (io.ReadCloser, string, string, error) {
doc, err := s.GetByID(ctx, tenantID, docID)
if err != nil {
return nil, "", "", err
}
if doc == nil {
return nil, "", "", fmt.Errorf("document not found")
}
if doc.FilePath == nil {
return nil, "", "", fmt.Errorf("document has no file")
}
body, contentType, err := s.storage.Download(ctx, documentBucket, *doc.FilePath)
if err != nil {
return nil, "", "", fmt.Errorf("downloading file: %w", err)
}
// Use stored mime_type if available, fall back to storage response
if doc.MimeType != nil && *doc.MimeType != "" {
contentType = *doc.MimeType
}
return body, contentType, doc.Title, nil
}
func (s *DocumentService) Delete(ctx context.Context, tenantID, docID, userID uuid.UUID) error {
doc, err := s.GetByID(ctx, tenantID, docID)
if err != nil {
return err
}
if doc == nil {
return sql.ErrNoRows
}
// Delete from storage
if doc.FilePath != nil {
if err := s.storage.Delete(ctx, documentBucket, []string{*doc.FilePath}); err != nil {
return fmt.Errorf("deleting file from storage: %w", err)
}
}
// Delete database record
_, err = s.db.ExecContext(ctx,
"DELETE FROM documents WHERE id = $1 AND tenant_id = $2", docID, tenantID)
if err != nil {
return fmt.Errorf("deleting document record: %w", err)
}
// Log case event
createEvent(ctx, s.db, tenantID, doc.CaseID, userID, "document_deleted",
fmt.Sprintf("Document deleted: %s", doc.Title), nil)
return nil
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -0,0 +1,193 @@
package services
import (
"fmt"
"strings"
"time"
"github.com/jmoiron/sqlx"
)
// Holiday represents a non-working day
type Holiday struct {
Date time.Time
Name string
IsVacation bool // Part of court vacation period
IsClosure bool // Single-day closure (public holiday)
}
// HolidayService manages holiday data and non-working day checks
type HolidayService struct {
db *sqlx.DB
// Cached holidays by year
cache map[int][]Holiday
}
// NewHolidayService creates a holiday service
func NewHolidayService(db *sqlx.DB) *HolidayService {
return &HolidayService{
db: db,
cache: make(map[int][]Holiday),
}
}
// dbHoliday matches the holidays table schema
type dbHoliday struct {
ID int `db:"id"`
Date time.Time `db:"date"`
Name string `db:"name"`
Country string `db:"country"`
State *string `db:"state"`
HolidayType string `db:"holiday_type"`
}
// LoadHolidaysForYear loads holidays from DB for a given year, merges with
// German federal holidays, and caches the result.
func (s *HolidayService) LoadHolidaysForYear(year int) ([]Holiday, error) {
if cached, ok := s.cache[year]; ok {
return cached, nil
}
holidays := make([]Holiday, 0, 30)
// Load from DB if available
if s.db != nil {
var dbHolidays []dbHoliday
err := s.db.Select(&dbHolidays,
`SELECT id, date, name, country, state, holiday_type
FROM holidays
WHERE EXTRACT(YEAR FROM date) = $1
ORDER BY date`, year)
if err == nil {
for _, h := range dbHolidays {
holidays = append(holidays, Holiday{
Date: h.Date,
Name: h.Name,
IsClosure: h.HolidayType == "public_holiday" || h.HolidayType == "closure",
IsVacation: h.HolidayType == "vacation",
})
}
}
// If DB query fails, fall through to hardcoded holidays
}
// Always add German federal holidays (if not already present from DB)
federal := germanFederalHolidays(year)
existing := make(map[string]bool, len(holidays))
for _, h := range holidays {
existing[h.Date.Format("2006-01-02")] = true
}
for _, h := range federal {
key := h.Date.Format("2006-01-02")
if !existing[key] {
holidays = append(holidays, h)
}
}
s.cache[year] = holidays
return holidays, nil
}
// IsHoliday checks if a date is a holiday
func (s *HolidayService) IsHoliday(date time.Time) *Holiday {
year := date.Year()
holidays, err := s.LoadHolidaysForYear(year)
if err != nil {
return nil
}
dateStr := date.Format("2006-01-02")
for i := range holidays {
if holidays[i].Date.Format("2006-01-02") == dateStr {
return &holidays[i]
}
}
return nil
}
// IsNonWorkingDay returns true if the date is a weekend or holiday
func (s *HolidayService) IsNonWorkingDay(date time.Time) bool {
wd := date.Weekday()
if wd == time.Saturday || wd == time.Sunday {
return true
}
return s.IsHoliday(date) != nil
}
// AdjustForNonWorkingDays moves the date to the next working day
// if it falls on a weekend or holiday.
// Returns adjusted date, original date, and whether adjustment was made.
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time) (adjusted time.Time, original time.Time, wasAdjusted bool) {
original = date
adjusted = date
// Safety limit: max 30 days forward
for i := 0; i < 30 && s.IsNonWorkingDay(adjusted); i++ {
adjusted = adjusted.AddDate(0, 0, 1)
wasAdjusted = true
}
return adjusted, original, wasAdjusted
}
// ClearCache clears the holiday cache (useful after DB updates)
func (s *HolidayService) ClearCache() {
s.cache = make(map[int][]Holiday)
}
// germanFederalHolidays returns all German federal public holidays for a year.
// These are holidays observed in all 16 German states.
func germanFederalHolidays(year int) []Holiday {
easterMonth, easterDay := CalculateEasterSunday(year)
easter := time.Date(year, time.Month(easterMonth), easterDay, 0, 0, 0, 0, time.UTC)
holidays := []Holiday{
{Date: time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC), Name: "Neujahr", IsClosure: true},
{Date: easter.AddDate(0, 0, -2), Name: "Karfreitag", IsClosure: true},
{Date: easter, Name: "Ostersonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 1), Name: "Ostermontag", IsClosure: true},
{Date: time.Date(year, time.May, 1, 0, 0, 0, 0, time.UTC), Name: "Tag der Arbeit", IsClosure: true},
{Date: easter.AddDate(0, 0, 39), Name: "Christi Himmelfahrt", IsClosure: true},
{Date: easter.AddDate(0, 0, 49), Name: "Pfingstsonntag", IsClosure: true},
{Date: easter.AddDate(0, 0, 50), Name: "Pfingstmontag", IsClosure: true},
{Date: time.Date(year, time.October, 3, 0, 0, 0, 0, time.UTC), Name: "Tag der Deutschen Einheit", IsClosure: true},
{Date: time.Date(year, time.December, 25, 0, 0, 0, 0, time.UTC), Name: "1. Weihnachtstag", IsClosure: true},
{Date: time.Date(year, time.December, 26, 0, 0, 0, 0, time.UTC), Name: "2. Weihnachtstag", IsClosure: true},
}
return holidays
}
// CalculateEasterSunday computes Easter Sunday using the Anonymous Gregorian algorithm.
// Returns month (1-12) and day.
func CalculateEasterSunday(year int) (int, int) {
a := year % 19
b := year / 100
c := year % 100
d := b / 4
e := b % 4
f := (b + 8) / 25
g := (b - f + 1) / 3
h := (19*a + b - d - g + 15) % 30
i := c / 4
k := c % 4
l := (32 + 2*e + 2*i - h - k) % 7
m := (a + 11*h + 22*l) / 451
month := (h + l - 7*m + 114) / 31
day := ((h + l - 7*m + 114) % 31) + 1
return month, day
}
// GetHolidaysForYear returns all holidays for a year (for API exposure)
func (s *HolidayService) GetHolidaysForYear(year int) ([]Holiday, error) {
return s.LoadHolidaysForYear(year)
}
// FormatHolidayList returns a simple string representation of holidays for debugging
func FormatHolidayList(holidays []Holiday) string {
var b strings.Builder
for _, h := range holidays {
fmt.Fprintf(&b, "%s: %s\n", h.Date.Format("2006-01-02"), h.Name)
}
return b.String()
}

View File

@@ -0,0 +1,121 @@
package services
import (
"testing"
"time"
)
func TestCalculateEasterSunday(t *testing.T) {
tests := []struct {
year int
wantMonth int
wantDay int
}{
{2024, 3, 31},
{2025, 4, 20},
{2026, 4, 5},
{2027, 3, 28},
}
for _, tt := range tests {
m, d := CalculateEasterSunday(tt.year)
if m != tt.wantMonth || d != tt.wantDay {
t.Errorf("CalculateEasterSunday(%d) = %d-%02d, want %d-%02d",
tt.year, m, d, tt.wantMonth, tt.wantDay)
}
}
}
func TestGermanFederalHolidays(t *testing.T) {
holidays := germanFederalHolidays(2026)
// Should have 11 federal holidays
if len(holidays) != 11 {
t.Fatalf("expected 11 federal holidays, got %d", len(holidays))
}
// Check Neujahr
if holidays[0].Name != "Neujahr" {
t.Errorf("first holiday should be Neujahr, got %s", holidays[0].Name)
}
if holidays[0].Date != time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) {
t.Errorf("Neujahr should be Jan 1, got %s", holidays[0].Date)
}
// Check Karfreitag 2026 (Easter = Apr 5, so Good Friday = Apr 3)
found := false
for _, h := range holidays {
if h.Name == "Karfreitag" {
found = true
expected := time.Date(2026, 4, 3, 0, 0, 0, 0, time.UTC)
if h.Date != expected {
t.Errorf("Karfreitag 2026 should be %s, got %s", expected, h.Date)
}
}
}
if !found {
t.Error("Karfreitag not found in holidays")
}
}
func TestHolidayServiceIsNonWorkingDay(t *testing.T) {
svc := NewHolidayService(nil) // no DB, uses hardcoded holidays
// Saturday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sat) {
t.Error("Saturday should be non-working day")
}
// Sunday
sun := time.Date(2026, 3, 29, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(sun) {
t.Error("Sunday should be non-working day")
}
// Regular Monday
mon := time.Date(2026, 3, 23, 0, 0, 0, 0, time.UTC)
if svc.IsNonWorkingDay(mon) {
t.Error("regular Monday should be a working day")
}
// Christmas (Friday Dec 25, 2026)
xmas := time.Date(2026, 12, 25, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(xmas) {
t.Error("Christmas should be non-working day")
}
// New Year
newyear := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if !svc.IsNonWorkingDay(newyear) {
t.Error("New Year should be non-working day")
}
}
func TestAdjustForNonWorkingDays(t *testing.T) {
svc := NewHolidayService(nil)
// Saturday -> Monday
sat := time.Date(2026, 3, 28, 0, 0, 0, 0, time.UTC)
adj, orig, adjusted := svc.AdjustForNonWorkingDays(sat)
if !adjusted {
t.Error("Saturday should be adjusted")
}
if orig != sat {
t.Error("original should be unchanged")
}
expected := time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC)
if adj != expected {
t.Errorf("Saturday should adjust to Monday %s, got %s", expected, adj)
}
// Regular Wednesday -> no adjustment
wed := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
adj, _, adjusted = svc.AdjustForNonWorkingDays(wed)
if adjusted {
t.Error("Wednesday should not be adjusted")
}
if adj != wed {
t.Error("non-adjusted date should be unchanged")
}
}

View File

@@ -0,0 +1,112 @@
package services
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// StorageClient interacts with Supabase Storage via REST API.
type StorageClient struct {
baseURL string
serviceKey string
httpClient *http.Client
}
func NewStorageClient(supabaseURL, serviceKey string) *StorageClient {
return &StorageClient{
baseURL: supabaseURL,
serviceKey: serviceKey,
httpClient: &http.Client{},
}
}
// Upload stores a file in the given bucket at the specified path.
func (s *StorageClient) Upload(ctx context.Context, bucket, path, contentType string, data io.Reader) error {
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, "POST", url, data)
if err != nil {
return fmt.Errorf("creating upload request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", contentType)
req.Header.Set("x-upsert", "true")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("uploading to storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage upload failed (status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// Download retrieves a file from storage. Caller must close the returned ReadCloser.
func (s *StorageClient) Download(ctx context.Context, bucket, path string) (io.ReadCloser, string, error) {
url := fmt.Sprintf("%s/storage/v1/object/%s/%s", s.baseURL, bucket, path)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, "", fmt.Errorf("creating download request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, "", fmt.Errorf("downloading from storage: %w", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, "", fmt.Errorf("file not found in storage")
}
body, _ := io.ReadAll(resp.Body)
return nil, "", fmt.Errorf("storage download failed (status %d): %s", resp.StatusCode, string(body))
}
ct := resp.Header.Get("Content-Type")
return resp.Body, ct, nil
}
// Delete removes files from storage by their paths.
func (s *StorageClient) Delete(ctx context.Context, bucket string, paths []string) error {
url := fmt.Sprintf("%s/storage/v1/object/%s", s.baseURL, bucket)
body, err := json.Marshal(map[string][]string{"prefixes": paths})
if err != nil {
return fmt.Errorf("marshaling delete request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "DELETE", url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("creating delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+s.serviceKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("deleting from storage: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("storage delete failed (status %d): %s", resp.StatusCode, string(respBody))
}
return nil
}

View File

@@ -0,0 +1,227 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
)
type TenantService struct {
db *sqlx.DB
}
func NewTenantService(db *sqlx.DB) *TenantService {
return &TenantService{db: db}
}
// Create creates a new tenant and assigns the creator as owner.
func (s *TenantService) Create(ctx context.Context, userID uuid.UUID, name, slug string) (*models.Tenant, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
var tenant models.Tenant
err = tx.QueryRowxContext(ctx,
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id, name, slug, settings, created_at, updated_at`,
name, slug,
).StructScan(&tenant)
if err != nil {
return nil, fmt.Errorf("insert tenant: %w", err)
}
_, err = tx.ExecContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, 'owner')`,
userID, tenant.ID,
)
if err != nil {
return nil, fmt.Errorf("assign owner: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
return &tenant, nil
}
// ListForUser returns all tenants the user belongs to.
func (s *TenantService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.TenantWithRole, error) {
var tenants []models.TenantWithRole
err := s.db.SelectContext(ctx, &tenants,
`SELECT t.id, t.name, t.slug, t.settings, t.created_at, t.updated_at, ut.role
FROM tenants t
JOIN user_tenants ut ON ut.tenant_id = t.id
WHERE ut.user_id = $1
ORDER BY t.name`,
userID,
)
if err != nil {
return nil, fmt.Errorf("list tenants: %w", err)
}
return tenants, nil
}
// GetByID returns a single tenant. The caller must verify the user has access.
func (s *TenantService) GetByID(ctx context.Context, tenantID uuid.UUID) (*models.Tenant, error) {
var tenant models.Tenant
err := s.db.GetContext(ctx, &tenant,
`SELECT id, name, slug, settings, created_at, updated_at FROM tenants WHERE id = $1`,
tenantID,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get tenant: %w", err)
}
return &tenant, nil
}
// GetUserRole returns the user's role in a tenant, or empty string if not a member.
func (s *TenantService) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) {
var role string
err := s.db.GetContext(ctx, &role,
`SELECT role FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID,
)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get user role: %w", err)
}
return role, nil
}
// FirstTenantForUser returns the user's first tenant (by name), used as default.
func (s *TenantService) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) {
var tenantID uuid.UUID
err := s.db.GetContext(ctx, &tenantID,
`SELECT t.id FROM tenants t
JOIN user_tenants ut ON ut.tenant_id = t.id
WHERE ut.user_id = $1
ORDER BY t.name LIMIT 1`,
userID,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("first tenant: %w", err)
}
return &tenantID, nil
}
// ListMembers returns all members of a tenant.
func (s *TenantService) ListMembers(ctx context.Context, tenantID uuid.UUID) ([]models.UserTenant, error) {
var members []models.UserTenant
err := s.db.SelectContext(ctx, &members,
`SELECT user_id, tenant_id, role, created_at FROM user_tenants WHERE tenant_id = $1 ORDER BY created_at`,
tenantID,
)
if err != nil {
return nil, fmt.Errorf("list members: %w", err)
}
return members, nil
}
// InviteByEmail looks up a user by email in auth.users and adds them to the tenant.
func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, email, role string) (*models.UserTenant, error) {
// Look up user in Supabase auth.users
var userID uuid.UUID
err := s.db.GetContext(ctx, &userID,
`SELECT id FROM auth.users WHERE email = $1`,
email,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no user found with email %s", email)
}
if err != nil {
return nil, fmt.Errorf("lookup user: %w", err)
}
// Check if already a member
var exists bool
err = s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
userID, tenantID,
)
if err != nil {
return nil, fmt.Errorf("check membership: %w", err)
}
if exists {
return nil, fmt.Errorf("user is already a member of this tenant")
}
var ut models.UserTenant
err = s.db.QueryRowxContext(ctx,
`INSERT INTO user_tenants (user_id, tenant_id, role) VALUES ($1, $2, $3)
RETURNING user_id, tenant_id, role, created_at`,
userID, tenantID, role,
).StructScan(&ut)
if err != nil {
return nil, fmt.Errorf("invite user: %w", err)
}
return &ut, nil
}
// UpdateSettings merges new settings into the tenant's existing settings JSONB.
func (s *TenantService) UpdateSettings(ctx context.Context, tenantID uuid.UUID, settings json.RawMessage) (*models.Tenant, error) {
var tenant models.Tenant
err := s.db.QueryRowxContext(ctx,
`UPDATE tenants SET settings = COALESCE(settings, '{}'::jsonb) || $1::jsonb, updated_at = NOW()
WHERE id = $2
RETURNING id, name, slug, settings, created_at, updated_at`,
settings, tenantID,
).StructScan(&tenant)
if err != nil {
return nil, fmt.Errorf("update settings: %w", err)
}
return &tenant, nil
}
// RemoveMember removes a user from a tenant. Cannot remove the last owner.
func (s *TenantService) RemoveMember(ctx context.Context, tenantID, userID uuid.UUID) error {
// Check if the user being removed is an owner
role, err := s.GetUserRole(ctx, userID, tenantID)
if err != nil {
return fmt.Errorf("check role: %w", err)
}
if role == "" {
return fmt.Errorf("user is not a member of this tenant")
}
if role == "owner" {
// Count owners — prevent removing the last one
var ownerCount int
err := s.db.GetContext(ctx, &ownerCount,
`SELECT COUNT(*) FROM user_tenants WHERE tenant_id = $1 AND role = 'owner'`,
tenantID,
)
if err != nil {
return fmt.Errorf("count owners: %w", err)
}
if ownerCount <= 1 {
return fmt.Errorf("cannot remove the last owner of a tenant")
}
}
_, err = s.db.ExecContext(ctx,
`DELETE FROM user_tenants WHERE user_id = $1 AND tenant_id = $2`,
userID, tenantID,
)
if err != nil {
return fmt.Errorf("remove member: %w", err)
}
return nil
}

View File

@@ -5,9 +5,16 @@
"": {
"name": "frontend",
"dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.100.0",
"@tanstack/react-query": "^5.95.2",
"date-fns": "^4.1.0",
"lucide-react": "^1.6.0",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-dropzone": "^15.0.0",
"sonner": "^2.0.7",
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -151,6 +158,22 @@
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
"@supabase/auth-js": ["@supabase/auth-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-pdT3ye3UVRN1Cg0wom6BmyY+XTtp5DiJaYnPi6j8ht5i8Lq8kfqxJMJz9GI9YDKk3w1nhGOPnh6Qz5qpyYm+1w=="],
"@supabase/functions-js": ["@supabase/functions-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-keLg79RPwP+uiwHuxFPTFgDRxPV46LM4j/swjyR2GKJgWniTVSsgiBHfbIBDcrQwehLepy09b/9QSHUywtKRWQ=="],
"@supabase/phoenix": ["@supabase/phoenix@0.4.0", "", {}, "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw=="],
"@supabase/postgrest-js": ["@supabase/postgrest-js@2.100.0", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-xYNvNbBJaXOGcrZ44wxwp5830uo1okMHGS8h8dm3u4f0xcZ39yzbryUsubTJW41MG2gbL/6U57cA4Pi6YMZ9pA=="],
"@supabase/realtime-js": ["@supabase/realtime-js@2.100.0", "", { "dependencies": { "@supabase/phoenix": "^0.4.0", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" } }, "sha512-2AZs00zzEF0HuCKY8grz5eCYlwEfVi5HONLZFoNR6aDfxQivl8zdQYNjyFoqN2MZiVhQHD7u6XV/xHwM8mCEHw=="],
"@supabase/ssr": ["@supabase/ssr@0.9.0", "", { "dependencies": { "cookie": "^1.0.2" }, "peerDependencies": { "@supabase/supabase-js": "^2.97.0" } }, "sha512-UFY6otYV3yqCgV+AyHj80vNkTvbf1Gas2LW4dpbQ4ap6p6v3eB2oaDfcI99jsuJzwVBCFU4BJI+oDYyhNk1z0Q=="],
"@supabase/storage-js": ["@supabase/storage-js@2.100.0", "", { "dependencies": { "iceberg-js": "^0.8.1", "tslib": "2.8.1" } }, "sha512-d4EeuK6RNIgYNA2MU9kj8lQrLm5AzZ+WwpWjGkii6SADQNIGTC/uiaTRu02XJ5AmFALQfo8fLl9xuCkO6Xw+iQ=="],
"@supabase/supabase-js": ["@supabase/supabase-js@2.100.0", "", { "dependencies": { "@supabase/auth-js": "2.100.0", "@supabase/functions-js": "2.100.0", "@supabase/postgrest-js": "2.100.0", "@supabase/realtime-js": "2.100.0", "@supabase/storage-js": "2.100.0" } }, "sha512-r0tlcukejJXJ1m/2eG/Ya5eYs4W8AC7oZfShpG3+SIo/eIU9uIt76ZeYI1SoUwUmcmzlAbgch+HDZDR/toVQPQ=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="],
@@ -183,6 +206,10 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "postcss": "^8.5.6", "tailwindcss": "4.2.2" } }, "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ=="],
"@tanstack/query-core": ["@tanstack/query-core@5.95.2", "", {}, "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -197,6 +224,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
@@ -287,6 +316,8 @@
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
@@ -319,6 +350,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -331,6 +364,8 @@
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -413,6 +448,8 @@
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -463,6 +500,8 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -583,6 +622,8 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lucide-react": ["lucide-react@1.6.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -659,6 +700,8 @@
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
"react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
@@ -705,6 +748,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
@@ -779,6 +824,8 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],

6
frontend/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -9,9 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.100.0",
"@tanstack/react-query": "^5.95.2",
"date-fns": "^4.1.0",
"lucide-react": "^1.6.0",
"next": "15.5.14",
"react": "19.1.0",
"react-dom": "19.1.0",
"next": "15.5.14"
"react-dropzone": "^15.0.0",
"sonner": "^2.0.7"
},
"devDependencies": {
"typescript": "^5",

View File

@@ -0,0 +1,142 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Brain } from "lucide-react";
import { api } from "@/lib/api";
import type {
Case,
ExtractedDeadline,
ExtractionResponse,
PaginatedResponse,
} from "@/lib/types";
import { ExtractionForm } from "@/components/ai/ExtractionForm";
import { ExtractionResults } from "@/components/ai/ExtractionResults";
export default function AIExtractPage() {
const router = useRouter();
const [selectedCaseId, setSelectedCaseId] = useState("");
const [isExtracting, setIsExtracting] = useState(false);
const [isAdopting, setIsAdopting] = useState(false);
const [results, setResults] = useState<ExtractedDeadline[] | null>(null);
const { data: casesData } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<PaginatedResponse<Case>>("/api/cases"),
});
const cases = casesData?.data ?? [];
async function handleExtract(file: File | null, text: string) {
setIsExtracting(true);
setResults(null);
try {
let response: ExtractionResponse;
if (file) {
const formData = new FormData();
formData.append("file", file);
response = await api.postFormData<ExtractionResponse>(
"/api/ai/extract-deadlines",
formData,
);
} else {
response = await api.post<ExtractionResponse>(
"/api/ai/extract-deadlines",
{ text },
);
}
setResults(response.deadlines);
if (response.count === 0) {
toast.info("Keine Fristen im Dokument gefunden.");
} else {
toast.success(`${response.count} Frist(en) erkannt.`);
}
} catch (err: unknown) {
const message =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Analyse fehlgeschlagen";
toast.error(message);
} finally {
setIsExtracting(false);
}
}
async function handleAdopt(deadlines: ExtractedDeadline[]) {
if (!selectedCaseId) return;
setIsAdopting(true);
try {
const promises = deadlines.map((d) =>
api.post(`/api/cases/${selectedCaseId}/deadlines`, {
title: d.title,
due_date: d.due_date ?? "",
source: "ai_extraction",
notes: [
d.rule_reference ? `Rechtsgrundlage: ${d.rule_reference}` : "",
d.source_quote ? `Quelle: "${d.source_quote}"` : "",
`Konfidenz: ${Math.round(d.confidence * 100)}%`,
]
.filter(Boolean)
.join("\n"),
}),
);
await Promise.all(promises);
toast.success(
`${deadlines.length} Frist(en) erfolgreich übernommen.`,
);
router.push(`/cases/${selectedCaseId}`);
} catch (err: unknown) {
const message =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Übernahme fehlgeschlagen";
toast.error(message);
} finally {
setIsAdopting(false);
}
}
return (
<div className="animate-fade-in mx-auto max-w-4xl">
<div className="mb-6 flex items-center gap-3">
<Brain className="h-5 w-5 text-neutral-500" />
<div>
<h1 className="text-lg font-semibold text-neutral-900">
AI Fristenanalyse
</h1>
<p className="text-sm text-neutral-500">
Fristen automatisch aus Dokumenten extrahieren
</p>
</div>
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-4 sm:p-6">
<ExtractionForm
cases={cases}
selectedCaseId={selectedCaseId}
onCaseChange={setSelectedCaseId}
onExtract={handleExtract}
isLoading={isExtracting}
/>
</div>
{results !== null && (
<div className="animate-fade-in mt-6 rounded-lg border border-neutral-200 bg-white p-4 sm:p-6">
<ExtractionResults
deadlines={results}
onAdopt={handleAdopt}
isAdopting={isAdopting}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,341 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { Case, CaseEvent, Party, Deadline, Document } from "@/lib/types";
import { CaseTimeline } from "@/components/cases/CaseTimeline";
import { PartyList } from "@/components/cases/PartyList";
import {
ArrowLeft,
Clock,
FileText,
Users,
Activity,
AlertTriangle,
} from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { useState } from "react";
import { Skeleton } from "@/components/ui/Skeleton";
interface CaseDetail extends Case {
parties: Party[];
recent_events: CaseEvent[];
deadlines_count: number;
}
const STATUS_BADGE: Record<string, string> = {
active: "bg-emerald-50 text-emerald-700",
pending: "bg-amber-50 text-amber-700",
closed: "bg-neutral-100 text-neutral-600",
archived: "bg-neutral-100 text-neutral-400",
};
const STATUS_LABEL: Record<string, string> = {
active: "Aktiv",
pending: "Anhängig",
closed: "Geschlossen",
archived: "Archiviert",
};
const TABS = [
{ key: "timeline", label: "Verlauf", icon: Activity },
{ key: "deadlines", label: "Fristen", icon: Clock },
{ key: "documents", label: "Dokumente", icon: FileText },
{ key: "parties", label: "Parteien", icon: Users },
] as const;
type TabKey = (typeof TABS)[number]["key"];
function CaseDetailSkeleton() {
return (
<div>
<Skeleton className="h-4 w-28" />
<div className="mt-4 flex items-start justify-between">
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<div className="space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-24" />
</div>
</div>
<div className="mt-6 flex gap-4 border-b border-neutral-200 pb-2.5">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-4 w-20" />
))}
</div>
<div className="mt-6 space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 rounded-md" />
))}
</div>
</div>
);
}
export default function CaseDetailPage() {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<TabKey>("timeline");
const {
data: caseDetail,
isLoading,
error,
} = useQuery({
queryKey: ["case", id],
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
});
const { data: deadlinesData } = useQuery({
queryKey: ["case-deadlines", id],
queryFn: () =>
api.get<{ deadlines: Deadline[]; total: number }>(
`/deadlines?case_id=${id}`,
),
enabled: activeTab === "deadlines",
});
const { data: documentsData } = useQuery({
queryKey: ["case-documents", id],
queryFn: () => api.get<Document[]>(`/cases/${id}/documents`),
enabled: activeTab === "documents",
});
if (isLoading) {
return <CaseDetailSkeleton />;
}
if (error || !caseDetail) {
return (
<div className="py-12 text-center">
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<p className="text-sm font-medium text-neutral-900">
Akte nicht gefunden
</p>
<p className="mt-1 text-sm text-neutral-500">
Die Akte existiert nicht oder Sie haben keine Berechtigung.
</p>
<Link
href="/cases"
className="mt-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Akten
</Link>
</div>
);
}
const deadlines = deadlinesData?.deadlines ?? [];
const documents = documentsData ?? [];
return (
<div className="animate-fade-in">
<Link
href="/cases"
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Akten
</Link>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-lg font-semibold text-neutral-900">
{caseDetail.title}
</h1>
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[caseDetail.status] ?? "bg-neutral-100 text-neutral-500"}`}
>
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-neutral-500">
<span>Az. {caseDetail.case_number}</span>
{caseDetail.case_type && <span>{caseDetail.case_type}</span>}
{caseDetail.court && <span>{caseDetail.court}</span>}
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
</div>
</div>
<div className="text-right text-xs text-neutral-400">
<p>
Erstellt:{" "}
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
locale: de,
})}
</p>
<p>
Aktualisiert:{" "}
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
locale: de,
})}
</p>
</div>
</div>
{caseDetail.ai_summary && (
<div className="mt-4 rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
{caseDetail.ai_summary}
</div>
)}
<div className="mt-6 border-b border-neutral-200">
<nav className="-mb-px flex gap-1 overflow-x-auto sm:gap-4">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? "border-neutral-900 text-neutral-900"
: "border-transparent text-neutral-400 hover:text-neutral-600"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
{tab.key === "deadlines" && caseDetail.deadlines_count > 0 && (
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
{caseDetail.deadlines_count}
</span>
)}
{tab.key === "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">
{caseDetail.parties.length}
</span>
)}
</button>
))}
</nav>
</div>
<div className="mt-6">
{activeTab === "timeline" && (
<CaseTimeline events={caseDetail.recent_events ?? []} />
)}
{activeTab === "deadlines" && (
<DeadlinesList deadlines={deadlines} />
)}
{activeTab === "documents" && (
<DocumentsList documents={documents} />
)}
{activeTab === "parties" && (
<PartyList caseId={id} parties={caseDetail.parties ?? []} />
)}
</div>
</div>
);
}
function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
if (deadlines.length === 0) {
return (
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Clock className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Fristen vorhanden.
</p>
</div>
);
}
const DEADLINE_STATUS: Record<string, string> = {
pending: "bg-amber-50 text-amber-700",
completed: "bg-emerald-50 text-emerald-700",
overdue: "bg-red-50 text-red-700",
};
const DEADLINE_STATUS_LABEL: Record<string, string> = {
pending: "Offen",
completed: "Erledigt",
overdue: "Überfällig",
};
return (
<div className="space-y-2">
{deadlines.map((d) => (
<div
key={d.id}
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
{d.description && (
<p className="mt-0.5 text-sm text-neutral-500">
{d.description}
</p>
)}
</div>
<div className="flex items-center gap-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${DEADLINE_STATUS[d.status] ?? "bg-neutral-100 text-neutral-500"}`}
>
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="whitespace-nowrap text-sm text-neutral-500">
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
</span>
</div>
</div>
))}
</div>
);
}
function DocumentsList({ documents }: { documents: Document[] }) {
if (documents.length === 0) {
return (
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<FileText className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Dokumente vorhanden.
</p>
</div>
);
}
return (
<div className="space-y-2">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
>
<div className="flex items-center gap-3">
<FileText className="h-4 w-4 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-900">
{doc.title}
</p>
<div className="flex gap-2 text-xs text-neutral-400">
{doc.doc_type && <span>{doc.doc_type}</span>}
{doc.file_size && (
<span>{(doc.file_size / 1024).toFixed(0)} KB</span>
)}
</div>
</div>
</div>
<a
href={`/api/documents/${doc.id}`}
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
Herunterladen
</a>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Case } from "@/lib/types";
import { CaseForm, type CaseFormData } from "@/components/cases/CaseForm";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function NewCasePage() {
const router = useRouter();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: CaseFormData) => api.post<Case>("/cases", data),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["cases"] });
toast.success("Akte angelegt");
router.push(`/cases/${created.id}`);
},
onError: () => {
toast.error("Fehler beim Anlegen der Akte");
},
});
return (
<div className="animate-fade-in mx-auto max-w-2xl">
<Link
href="/cases"
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Akten
</Link>
<h1 className="text-lg font-semibold text-neutral-900">Neue Akte</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Neue Akte im System anlegen
</p>
<div className="mt-6 rounded-md border border-neutral-200 bg-white p-4 sm:p-6">
<CaseForm
onSubmit={(data) => mutation.mutate(data)}
isSubmitting={mutation.isPending}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Case } from "@/lib/types";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { Plus, Search, FolderOpen } from "lucide-react";
import { useState } from "react";
import { SkeletonTable } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
const STATUS_OPTIONS = [
{ value: "", label: "Alle Status" },
{ value: "active", label: "Aktiv" },
{ value: "pending", label: "Anhängig" },
{ value: "closed", label: "Geschlossen" },
{ value: "archived", label: "Archiviert" },
];
const TYPE_OPTIONS = [
{ value: "", label: "Alle Typen" },
{ value: "INF", label: "Verletzungsklage" },
{ value: "REV", label: "Widerruf" },
{ value: "CCR", label: "Einstweilige Verfügung" },
{ value: "APP", label: "Berufung" },
{ value: "PI", label: "Vorläufiger Rechtsschutz" },
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
];
const STATUS_BADGE: Record<string, string> = {
active: "bg-emerald-50 text-emerald-700",
pending: "bg-amber-50 text-amber-700",
closed: "bg-neutral-100 text-neutral-600",
archived: "bg-neutral-100 text-neutral-400",
};
const STATUS_LABEL: Record<string, string> = {
active: "Aktiv",
pending: "Anhängig",
closed: "Geschlossen",
archived: "Archiviert",
};
const inputClass =
"rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
export default function CasesPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [search, setSearch] = useState(searchParams.get("search") ?? "");
const [status, setStatus] = useState(searchParams.get("status") ?? "");
const [type, setType] = useState(searchParams.get("type") ?? "");
const { data, isLoading } = useQuery({
queryKey: ["cases", { search, status, type }],
queryFn: () => {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (status) params.set("status", status);
if (type) params.set("type", type);
params.set("limit", "50");
const qs = params.toString();
return api.get<{ cases: Case[]; total: number }>(
`/cases${qs ? `?${qs}` : ""}`,
);
},
});
const cases = data?.cases ?? [];
return (
<div className="animate-fade-in">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Akten</h1>
<p className="mt-0.5 text-sm text-neutral-500">
{data ? `${data.total} Akten` : "\u00A0"}
</p>
</div>
<Link
href="/cases/new"
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte
</Link>
</div>
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder="Suchen nach Aktenzeichen, Titel..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={`w-full pl-9 pr-3 ${inputClass}`}
/>
</div>
<div className="flex gap-3">
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className={inputClass}
>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={type}
onChange={(e) => setType(e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="mt-4">
{isLoading ? (
<SkeletonTable rows={5} />
) : cases.length === 0 ? (
<EmptyState
icon={FolderOpen}
title="Keine Akten gefunden"
description={
search || status || type
? "Versuchen Sie andere Suchkriterien."
: "Erstellen Sie Ihre erste Akte, um loszulegen."
}
action={
!search && !status && !type ? (
<Link
href="/cases/new"
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<Plus className="h-4 w-4" />
Neue Akte anlegen
</Link>
) : undefined
}
/>
) : (
<div className="-mx-4 overflow-x-auto sm:mx-0">
<div className="min-w-[640px] sm:min-w-0">
<div className="overflow-hidden rounded-md border border-neutral-200 bg-white">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-100 text-left text-xs font-medium uppercase tracking-wider text-neutral-400">
<th className="px-4 py-2.5">Aktenzeichen</th>
<th className="px-4 py-2.5">Titel</th>
<th className="hidden px-4 py-2.5 md:table-cell">Typ</th>
<th className="hidden px-4 py-2.5 lg:table-cell">
Gericht
</th>
<th className="px-4 py-2.5">Status</th>
<th className="hidden px-4 py-2.5 sm:table-cell">
Erstellt
</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{cases.map((c) => (
<tr
key={c.id}
onClick={() => router.push(`/cases/${c.id}`)}
className="cursor-pointer transition-colors hover:bg-neutral-50"
>
<td className="whitespace-nowrap px-4 py-2.5 font-medium text-neutral-900">
{c.case_number}
</td>
<td className="max-w-[200px] truncate px-4 py-2.5 text-neutral-700">
{c.title}
</td>
<td className="hidden px-4 py-2.5 text-neutral-500 md:table-cell">
{c.case_type ?? "-"}
</td>
<td className="hidden px-4 py-2.5 text-neutral-500 lg:table-cell">
{c.court ?? "-"}
</td>
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[c.status] ?? "bg-neutral-100 text-neutral-500"}`}
>
{STATUS_LABEL[c.status] ?? c.status}
</span>
</td>
<td className="hidden whitespace-nowrap px-4 py-2.5 text-neutral-400 sm:table-cell">
{new Date(c.created_at).toLocaleDateString("de-DE")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { DashboardData } from "@/lib/types";
import { DeadlineTrafficLights } from "@/components/dashboard/DeadlineTrafficLights";
import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid";
import { UpcomingTimeline } from "@/components/dashboard/UpcomingTimeline";
import { AISummaryCard } from "@/components/dashboard/AISummaryCard";
import { QuickActions } from "@/components/dashboard/QuickActions";
import { Skeleton, SkeletonCard } from "@/components/ui/Skeleton";
import { AlertTriangle, RefreshCw } from "lucide-react";
function DashboardSkeleton() {
return (
<div className="mx-auto max-w-6xl space-y-6">
<div>
<Skeleton className="h-5 w-28" />
<Skeleton className="mt-2 h-3.5 w-52" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-28 rounded-xl" />
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<SkeletonCard className="min-h-[200px]" />
</div>
<div className="space-y-6">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
</div>
</div>
);
}
export default function DashboardPage() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ["dashboard"],
queryFn: () => api.get<DashboardData>("/dashboard"),
refetchInterval: 60_000,
});
if (isLoading) {
return <DashboardSkeleton />;
}
if (error || !data) {
return (
<div className="mx-auto max-w-md py-16 text-center">
<div className="mx-auto mb-3 rounded-xl bg-red-50 p-3 w-fit">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h2 className="text-sm font-medium text-neutral-900">
Dashboard konnte nicht geladen werden
</h2>
<p className="mt-1 text-sm text-neutral-500">
Bitte versuchen Sie es erneut oder prüfen Sie Ihre Verbindung.
</p>
<button
onClick={() => refetch()}
className="mt-4 inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
<RefreshCw className="h-3.5 w-3.5" />
Erneut laden
</button>
</div>
);
}
return (
<div className="animate-fade-in mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Fristenübersicht und Kanzlei-Status
</p>
</div>
<DeadlineTrafficLights data={data.deadline_summary} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<UpcomingTimeline
deadlines={data.upcoming_deadlines}
appointments={data.upcoming_appointments}
/>
</div>
<div className="space-y-6">
<CaseOverviewGrid data={data.case_summary} />
<AISummaryCard data={data} />
<QuickActions />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Settings, Calendar, Users } from "lucide-react";
import Link from "next/link";
import { api } from "@/lib/api";
import type { Tenant } from "@/lib/types";
import { CalDAVSettings } from "@/components/settings/CalDAVSettings";
import { SkeletonCard } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
export default function EinstellungenPage() {
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const {
data: tenant,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["tenant-current", tenantId],
queryFn: () => api.get<Tenant>(`/api/tenants/${tenantId}`),
enabled: !!tenantId,
});
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-neutral-900">
Einstellungen
</h1>
<Link
href="/einstellungen/team"
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
>
<Users className="h-3.5 w-3.5" />
Team verwalten
</Link>
</div>
{/* Tenant Info */}
{isLoading ? (
<>
<SkeletonCard />
<SkeletonCard />
</>
) : error ? (
<EmptyState
icon={Settings}
title="Fehler beim Laden"
description="Einstellungen konnten nicht geladen werden."
action={
<button
onClick={() => refetch()}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800"
>
Erneut versuchen
</button>
}
/>
) : tenant ? (
<>
{/* Kanzlei Info */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
<Settings className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-900">
Kanzlei
</h2>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<p className="text-xs text-neutral-500">Name</p>
<p className="text-sm font-medium text-neutral-900">
{tenant.name}
</p>
</div>
<div>
<p className="text-xs text-neutral-500">Slug</p>
<p className="text-sm font-medium text-neutral-900">
{tenant.slug}
</p>
</div>
<div>
<p className="text-xs text-neutral-500">Erstellt am</p>
<p className="text-sm text-neutral-700">
{new Date(tenant.created_at).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</p>
</div>
</div>
</section>
{/* CalDAV Settings */}
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2.5 border-b border-neutral-100 pb-3">
<Calendar className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-900">
CalDAV-Synchronisierung
</h2>
</div>
<div className="mt-4">
<CalDAVSettings tenant={tenant} />
</div>
</section>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { ArrowLeft, Users } from "lucide-react";
import { TeamSettings } from "@/components/settings/TeamSettings";
export default function TeamPage() {
return (
<div className="mx-auto max-w-3xl space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-3">
<Link
href="/einstellungen"
className="rounded-md p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<div className="flex items-center gap-2.5">
<Users className="h-4 w-4 text-neutral-500" />
<h1 className="text-lg font-semibold text-neutral-900">
Team verwalten
</h1>
</div>
</div>
<section className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="border-b border-neutral-100 pb-3">
<h2 className="text-sm font-semibold text-neutral-900">
Mitglieder
</h2>
<p className="mt-0.5 text-xs text-neutral-500">
Benutzer einladen und Rollen verwalten
</p>
</div>
<div className="mt-4">
<TeamSettings />
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { DeadlineList } from "@/components/deadlines/DeadlineList";
import { DeadlineCalendarView } from "@/components/deadlines/DeadlineCalendarView";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Deadline } from "@/lib/types";
import { Calendar, List, Calculator } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
type ViewMode = "list" | "calendar";
export default function FristenPage() {
const [view, setView] = useState<ViewMode>("list");
const { data: deadlines } = useQuery({
queryKey: ["deadlines"],
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
});
return (
<div className="animate-fade-in space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Fristen</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Alle Fristen im Überblick
</p>
</div>
<div className="flex items-center gap-2">
<Link
href="/fristen/rechner"
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
>
<Calculator className="h-3.5 w-3.5" />
Fristenrechner
</Link>
<div className="flex rounded-md border border-neutral-200 bg-white">
<button
onClick={() => setView("list")}
className={`flex items-center gap-1 rounded-l-md px-2.5 py-1.5 text-sm transition-colors ${
view === "list"
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
<List className="h-3.5 w-3.5" />
Liste
</button>
<button
onClick={() => setView("calendar")}
className={`flex items-center gap-1 rounded-r-md px-2.5 py-1.5 text-sm transition-colors ${
view === "calendar"
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
<Calendar className="h-3.5 w-3.5" />
Kalender
</button>
</div>
</div>
</div>
{view === "list" ? (
<DeadlineList />
) : (
<DeadlineCalendarView deadlines={deadlines || []} />
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function FristenrechnerPage() {
return (
<div className="animate-fade-in space-y-4">
<div>
<Link
href="/fristen"
className="mb-2 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<ArrowLeft className="h-3.5 w-3.5" />
Zurück zu Fristen
</Link>
<h1 className="text-lg font-semibold text-neutral-900">
Fristenrechner
</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Berechnen Sie Fristen basierend auf Verfahrensart und Auslösedatum
</p>
</div>
<DeadlineCalculator />
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Sidebar } from "@/components/layout/Sidebar";
import { Header } from "@/components/layout/Header";
export const dynamic = "force-dynamic";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-y-auto p-4 sm:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,99 @@
"use client";
import { AppointmentList } from "@/components/appointments/AppointmentList";
import { AppointmentCalendar } from "@/components/appointments/AppointmentCalendar";
import { AppointmentModal } from "@/components/appointments/AppointmentModal";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Appointment } from "@/lib/types";
import { Calendar, List, Plus } from "lucide-react";
import { useState } from "react";
type ViewMode = "list" | "calendar";
export default function TerminePage() {
const [view, setView] = useState<ViewMode>("list");
const [modalOpen, setModalOpen] = useState(false);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const { data: appointments } = useQuery({
queryKey: ["appointments"],
queryFn: () => api.get<Appointment[]>("/api/appointments"),
});
function handleEdit(appointment: Appointment) {
setEditingAppointment(appointment);
setModalOpen(true);
}
function handleCreate() {
setEditingAppointment(null);
setModalOpen(true);
}
function handleClose() {
setModalOpen(false);
setEditingAppointment(null);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-neutral-900">Termine</h1>
<p className="mt-0.5 text-sm text-neutral-500">
Alle Termine im Uberblick
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCreate}
className="flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800"
>
<Plus className="h-3.5 w-3.5" />
Neuer Termin
</button>
<div className="flex rounded-md border border-neutral-200 bg-white">
<button
onClick={() => setView("list")}
className={`flex items-center gap-1 rounded-l-md px-2.5 py-1.5 text-sm transition-colors ${
view === "list"
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
<List className="h-3.5 w-3.5" />
Liste
</button>
<button
onClick={() => setView("calendar")}
className={`flex items-center gap-1 rounded-r-md px-2.5 py-1.5 text-sm transition-colors ${
view === "calendar"
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
<Calendar className="h-3.5 w-3.5" />
Kalender
</button>
</div>
</div>
</div>
{view === "list" ? (
<AppointmentList onEdit={handleEdit} />
) : (
<AppointmentCalendar
appointments={appointments || []}
onAppointmentClick={handleEdit}
/>
)}
<AppointmentModal
open={modalOpen}
onClose={handleClose}
appointment={editingAppointment}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function CallbackPage() {
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.onAuthStateChange((event) => {
if (event === "SIGNED_IN") {
router.push("/");
router.refresh();
}
});
}, [router, supabase.auth]);
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<p className="text-sm text-neutral-500">Authentifizierung...</p>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export const dynamic = "force-dynamic";
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,189 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mode, setMode] = useState<"password" | "magic">("password");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [magicSent, setMagicSent] = useState(false);
const router = useRouter();
const supabase = createClient();
async function handlePasswordLogin(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
router.push("/");
router.refresh();
}
async function handleMagicLink(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (error) {
setError(error.message);
setLoading(false);
return;
}
setMagicSent(true);
setLoading(false);
}
if (magicSent) {
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
Link gesendet
</h1>
<p className="mt-2 text-sm text-neutral-500">
Wir haben einen Login-Link an{" "}
<span className="font-medium text-neutral-700">{email}</span>{" "}
gesendet. Bitte pruefen Sie Ihren Posteingang.
</p>
</div>
<button
onClick={() => setMagicSent(false)}
className="w-full text-center text-sm text-neutral-500 hover:text-neutral-700"
>
Zurueck zum Login
</button>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Melden Sie sich an
</p>
</div>
<div className="flex rounded-md border border-neutral-200 bg-neutral-50 p-0.5">
<button
onClick={() => setMode("password")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "password"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Passwort
</button>
<button
onClick={() => setMode("magic")}
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
mode === "magic"
? "bg-white text-neutral-900 shadow-sm"
: "text-neutral-500 hover:text-neutral-700"
}`}
>
Magic Link
</button>
</div>
<form
onSubmit={mode === "password" ? handlePasswordLogin : handleMagicLink}
className="space-y-4"
>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
{mode === "password" && (
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
</div>
)}
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading
? "..."
: mode === "password"
? "Anmelden"
: "Link senden"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Noch kein Konto?{" "}
<a
href="/register"
className="font-medium text-neutral-900 hover:underline"
>
Registrieren
</a>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [firmName, setFirmName] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
// 1. Create auth user
const { data, error: authError } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/callback`,
},
});
if (authError) {
setError(authError.message);
setLoading(false);
return;
}
// 2. Create tenant via backend (the backend adds the user as owner)
if (data.session) {
try {
await api.post("/tenants", { name: firmName });
} catch (err: unknown) {
const apiErr = err as { error?: string };
setError(apiErr.error || "Kanzlei konnte nicht erstellt werden");
setLoading(false);
return;
}
router.push("/");
router.refresh();
} else {
// Email confirmation required
router.push("/login");
}
setLoading(false);
}
return (
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<div className="w-full max-w-sm space-y-6 rounded-lg border border-neutral-200 bg-white p-8">
<div className="text-center">
<h1 className="text-lg font-semibold text-neutral-900">
KanzlAI
</h1>
<p className="mt-1 text-sm text-neutral-500">
Erstellen Sie Ihr Konto
</p>
</div>
<form onSubmit={handleRegister} className="space-y-4">
<div>
<label
htmlFor="firm"
className="block text-sm font-medium text-neutral-700"
>
Kanzleiname
</label>
<input
id="firm"
type="text"
value={firmName}
onChange={(e) => setFirmName(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="Muster & Partner Rechtsanwaelte"
/>
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-neutral-700"
>
E-Mail
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
placeholder="anwalt@kanzlei.de"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-neutral-700"
>
Passwort
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="mt-1 block w-full rounded-md border border-neutral-300 px-3 py-2 text-sm placeholder-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900"
/>
<p className="mt-1 text-xs text-neutral-400">Mindestens 8 Zeichen</p>
</div>
{error && (
<p className="text-sm text-red-600">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 disabled:opacity-50"
>
{loading ? "..." : "Konto erstellen"}
</button>
</form>
<p className="text-center text-sm text-neutral-500">
Bereits registriert?{" "}
<a
href="/login"
className="font-medium text-neutral-900 hover:underline"
>
Anmelden
</a>
</p>
</div>
</div>
);
}

View File

@@ -1,26 +1,67 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Focus-visible ring for accessibility */
*:focus-visible {
outline: 2px solid #404040;
outline-offset: 2px;
border-radius: 4px;
}
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
}
@keyframes count-up {
0% {
transform: translateY(8px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.animate-count-up {
animation: count-up 0.3s ease-out;
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
@keyframes slide-in-left {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slide-in-left 0.2s ease-out;
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Providers } from "@/components/Providers";
import "./globals.css";
const geistSans = Geist({
@@ -13,7 +14,7 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "KanzlAI-mGMT",
title: "KanzlAI",
description: "Kanzleimanagement online",
};
@@ -23,11 +24,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="de">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
<Providers>{children}</Providers>
</body>
</html>
);

View File

@@ -1,7 +0,0 @@
export default function Home() {
return (
<main className="flex min-h-screen items-center justify-center">
<h1 className="text-4xl font-bold">KanzlAI-mGMT</h1>
</main>
);
}

View File

@@ -0,0 +1,26 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="bottom-right" richColors />
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import type { Case } from "@/lib/types";
interface ExtractionFormProps {
cases: Case[];
selectedCaseId: string;
onCaseChange: (caseId: string) => void;
onExtract: (file: File | null, text: string) => void;
isLoading: boolean;
}
const inputClass =
"w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
export function ExtractionForm({
cases,
selectedCaseId,
onCaseChange,
onExtract,
isLoading,
}: ExtractionFormProps) {
const [file, setFile] = useState<File | null>(null);
const [text, setText] = useState("");
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setText("");
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "application/pdf": [".pdf"] },
maxFiles: 1,
disabled: isLoading,
});
function removeFile() {
setFile(null);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedCaseId || (!file && !text.trim())) return;
onExtract(file, text.trim());
}
const hasInput = file !== null || text.trim().length > 0;
return (
<form onSubmit={handleSubmit} className="space-y-5">
{/* Case selector */}
<div>
<label
htmlFor="case-select"
className="mb-1.5 block text-sm font-medium text-neutral-700"
>
Akte
</label>
<select
id="case-select"
value={selectedCaseId}
onChange={(e) => onCaseChange(e.target.value)}
className={inputClass}
disabled={isLoading}
>
<option value="">Akte auswählen...</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} - {c.title}
</option>
))}
</select>
</div>
{/* PDF dropzone */}
<div>
<label className="mb-1.5 block text-sm font-medium text-neutral-700">
PDF hochladen
</label>
{file ? (
<div className="flex items-center gap-3 rounded-md border border-neutral-200 bg-neutral-50 px-4 py-3">
<FileText className="h-5 w-5 shrink-0 text-neutral-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{file.name}
</p>
<p className="text-xs text-neutral-500">
{(file.size / 1024).toFixed(0)} KB
</p>
</div>
<button
type="button"
onClick={removeFile}
disabled={isLoading}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-200 hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
</div>
) : (
<div
{...getRootProps()}
className={`cursor-pointer rounded-md border-2 border-dashed px-6 py-8 text-center transition-colors ${
isDragActive
? "border-neutral-500 bg-neutral-50"
: "border-neutral-300 hover:border-neutral-400"
} ${isLoading ? "pointer-events-none opacity-50" : ""}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-8 w-8 text-neutral-400" />
<p className="mt-2 text-sm text-neutral-600">
PDF hierher ziehen oder{" "}
<span className="font-medium text-neutral-900">durchsuchen</span>
</p>
<p className="mt-1 text-xs text-neutral-400">Nur PDF-Dateien</p>
</div>
)}
</div>
{/* Divider */}
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-neutral-200" />
<span className="text-xs text-neutral-400">oder</span>
<div className="h-px flex-1 bg-neutral-200" />
</div>
{/* Text input */}
<div>
<label
htmlFor="text-input"
className="mb-1.5 block text-sm font-medium text-neutral-700"
>
Text eingeben
</label>
<textarea
id="text-input"
value={text}
onChange={(e) => {
setText(e.target.value);
if (e.target.value.trim()) setFile(null);
}}
placeholder="Gerichtsschriftsatz, Beschluss oder sonstigen Text hier einfügen..."
rows={6}
disabled={isLoading}
className={`${inputClass} resize-y placeholder:text-neutral-400 disabled:opacity-50`}
/>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading || !hasInput || !selectedCaseId}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Analysiere...
</>
) : (
"Analysieren"
)}
</button>
</form>
);
}

View File

@@ -0,0 +1,292 @@
"use client";
import { useState } from "react";
import { Trash2, Check, Pencil, X, Loader2, Brain } from "lucide-react";
import type { ExtractedDeadline } from "@/lib/types";
interface ExtractionResultsProps {
deadlines: ExtractedDeadline[];
onAdopt: (deadlines: ExtractedDeadline[]) => void;
isAdopting: boolean;
}
function confidenceColor(confidence: number): string {
if (confidence >= 0.8) return "bg-green-100 text-green-800";
if (confidence >= 0.5) return "bg-yellow-100 text-yellow-800";
return "bg-red-100 text-red-800";
}
function confidenceLabel(confidence: number): string {
if (confidence >= 0.8) return "Hoch";
if (confidence >= 0.5) return "Mittel";
return "Niedrig";
}
const editInputClass =
"w-full rounded border border-neutral-300 px-2 py-1 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
export function ExtractionResults({
deadlines: initialDeadlines,
onAdopt,
isAdopting,
}: ExtractionResultsProps) {
const [deadlines, setDeadlines] = useState(initialDeadlines);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editForm, setEditForm] = useState<ExtractedDeadline | null>(null);
function removeDeadline(index: number) {
setDeadlines((prev) => prev.filter((_, i) => i !== index));
}
function startEdit(index: number) {
setEditingIndex(index);
setEditForm({ ...deadlines[index] });
}
function cancelEdit() {
setEditingIndex(null);
setEditForm(null);
}
function saveEdit() {
if (editingIndex === null || !editForm) return;
setDeadlines((prev) =>
prev.map((d, i) => (i === editingIndex ? editForm : d)),
);
setEditingIndex(null);
setEditForm(null);
}
if (deadlines.length === 0) {
return (
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Brain className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Fristen gefunden. Alle extrahierten Fristen wurden entfernt.
</p>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 className="text-sm font-medium text-neutral-900">
{deadlines.length} Frist{deadlines.length !== 1 ? "en" : ""} erkannt
</h3>
<button
onClick={() => onAdopt(deadlines)}
disabled={isAdopting || deadlines.length === 0}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{isAdopting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Übernehme...
</>
) : (
<>
<Check className="h-4 w-4" />
Fristen übernehmen
</>
)}
</button>
</div>
{/* Mobile: card layout, Desktop: table */}
<div className="hidden overflow-hidden rounded-md border border-neutral-200 sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200 bg-neutral-50">
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Frist
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Fälligkeitsdatum
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Rechtsgrundlage
</th>
<th className="px-4 py-2.5 text-left font-medium text-neutral-700">
Konfidenz
</th>
<th className="hidden px-4 py-2.5 text-left font-medium text-neutral-700 lg:table-cell">
Quellenangabe
</th>
<th className="px-4 py-2.5 text-right font-medium text-neutral-700">
Aktionen
</th>
</tr>
</thead>
<tbody>
{deadlines.map((d, i) => (
<tr
key={i}
className="border-b border-neutral-100 transition-colors last:border-b-0 hover:bg-neutral-50"
>
{editingIndex === i && editForm ? (
<>
<td className="px-4 py-2">
<input
value={editForm.title}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
className={editInputClass}
/>
</td>
<td className="px-4 py-2">
<input
type="date"
value={editForm.due_date ?? ""}
onChange={(e) =>
setEditForm({
...editForm,
due_date: e.target.value || null,
})
}
className={editInputClass}
/>
</td>
<td className="px-4 py-2">
<input
value={editForm.rule_reference}
onChange={(e) =>
setEditForm({
...editForm,
rule_reference: e.target.value,
})
}
className={editInputClass}
/>
</td>
<td className="px-4 py-2">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${confidenceColor(editForm.confidence)}`}
>
{confidenceLabel(editForm.confidence)}
</span>
</td>
<td className="hidden px-4 py-2 text-xs text-neutral-500 lg:table-cell">
{editForm.source_quote}
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={saveEdit}
className="rounded p-1 text-green-600 transition-colors hover:bg-green-50"
title="Speichern"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={cancelEdit}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100"
title="Abbrechen"
>
<X className="h-4 w-4" />
</button>
</div>
</td>
</>
) : (
<>
<td className="px-4 py-2.5 font-medium text-neutral-900">
{d.title}
</td>
<td className="px-4 py-2.5 text-neutral-700">
{d.due_date
? new Date(d.due_date).toLocaleDateString("de-DE")
: `${d.duration_value} ${d.duration_unit}`}
</td>
<td className="px-4 py-2.5 text-neutral-600">
{d.rule_reference || "-"}
</td>
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${confidenceColor(d.confidence)}`}
>
{confidenceLabel(d.confidence)}{" "}
{Math.round(d.confidence * 100)}%
</span>
</td>
<td className="hidden max-w-48 truncate px-4 py-2.5 text-xs text-neutral-500 lg:table-cell">
{d.source_quote || "-"}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => startEdit(i)}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeDeadline(i)}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
title="Entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile card layout */}
<div className="space-y-3 sm:hidden">
{deadlines.map((d, i) => (
<div
key={i}
className="rounded-md border border-neutral-200 bg-white p-4"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => startEdit(i)}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeDeadline(i)}
className="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-neutral-500">
<span>
{d.due_date
? new Date(d.due_date).toLocaleDateString("de-DE")
: `${d.duration_value} ${d.duration_unit}`}
</span>
{d.rule_reference && (
<>
<span>·</span>
<span>{d.rule_reference}</span>
</>
)}
<span
className={`rounded-full px-2 py-0.5 font-medium ${confidenceColor(d.confidence)}`}
>
{confidenceLabel(d.confidence)} {Math.round(d.confidence * 100)}
%
</span>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import type { Appointment } from "@/lib/types";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
parseISO,
addMonths,
subMonths,
} from "date-fns";
import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState, useMemo } from "react";
const TYPE_DOT_COLORS: Record<string, string> = {
hearing: "bg-blue-500",
meeting: "bg-violet-500",
consultation: "bg-emerald-500",
deadline_hearing: "bg-amber-500",
other: "bg-neutral-400",
};
interface AppointmentCalendarProps {
appointments: Appointment[];
onDayClick?: (date: string) => void;
onAppointmentClick?: (appointment: Appointment) => void;
}
export function AppointmentCalendar({
appointments,
onDayClick,
onAppointmentClick,
}: AppointmentCalendarProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calStart = startOfWeek(monthStart, { weekStartsOn: 1 });
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
const days = eachDayOfInterval({ start: calStart, end: calEnd });
const appointmentsByDay = useMemo(() => {
const map = new Map<string, Appointment[]>();
for (const a of appointments) {
const key = a.start_at.slice(0, 10);
const existing = map.get(key) || [];
existing.push(a);
map.set(key, existing);
}
return map;
}, [appointments]);
const weekDays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
return (
<div className="rounded-lg border border-neutral-200 bg-white">
{/* Header */}
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
<button
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm font-medium text-neutral-900">
{format(currentMonth, "MMMM yyyy", { locale: de })}
</span>
<button
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
{/* Weekday labels */}
<div className="grid grid-cols-7 border-b border-neutral-100">
{weekDays.map((d) => (
<div key={d} className="px-2 py-2 text-center text-xs font-medium text-neutral-400">
{d}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7">
{days.map((day, i) => {
const key = format(day, "yyyy-MM-dd");
const dayAppointments = appointmentsByDay.get(key) || [];
const inMonth = isSameMonth(day, currentMonth);
const today = isToday(day);
return (
<div
key={i}
onClick={() => onDayClick?.(key)}
className={`min-h-[5rem] cursor-pointer border-b border-r border-neutral-100 p-1.5 transition-colors hover:bg-neutral-50 ${
!inMonth ? "bg-neutral-50/50" : ""
}`}
>
<div
className={`mb-1 text-right text-xs ${
today
? "font-bold text-neutral-900"
: inMonth
? "text-neutral-600"
: "text-neutral-300"
}`}
>
{today ? (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-neutral-900 text-white">
{format(day, "d")}
</span>
) : (
format(day, "d")
)}
</div>
<div className="space-y-0.5">
{dayAppointments.slice(0, 3).map((appt) => {
const dotColor =
TYPE_DOT_COLORS[appt.appointment_type ?? "other"] ?? TYPE_DOT_COLORS.other;
return (
<div
key={appt.id}
onClick={(e) => {
e.stopPropagation();
onAppointmentClick?.(appt);
}}
className="flex items-center gap-1 truncate rounded px-0.5 hover:bg-neutral-100"
title={`${format(parseISO(appt.start_at), "HH:mm")} ${appt.title}`}
>
<div className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotColor}`} />
<span className="truncate text-[10px] text-neutral-700">
<span className="font-medium">
{format(parseISO(appt.start_at), "HH:mm")}
</span>{" "}
{appt.title}
</span>
</div>
);
})}
{dayAppointments.length > 3 && (
<div className="text-[10px] text-neutral-400">
+{dayAppointments.length - 3} mehr
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,265 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Appointment, Case } from "@/lib/types";
import { format, parseISO, isToday, isTomorrow, isThisWeek, isPast } from "date-fns";
import { de } from "date-fns/locale";
import { Calendar, Filter, MapPin, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useState, useMemo } from "react";
const TYPE_LABELS: Record<string, string> = {
hearing: "Verhandlung",
meeting: "Besprechung",
consultation: "Beratung",
deadline_hearing: "Fristanhorung",
other: "Sonstiges",
};
const TYPE_COLORS: Record<string, string> = {
hearing: "bg-blue-100 text-blue-700",
meeting: "bg-violet-100 text-violet-700",
consultation: "bg-emerald-100 text-emerald-700",
deadline_hearing: "bg-amber-100 text-amber-700",
other: "bg-neutral-100 text-neutral-600",
};
interface AppointmentListProps {
onEdit: (appointment: Appointment) => void;
}
function groupByDate(appointments: Appointment[]): Map<string, Appointment[]> {
const groups = new Map<string, Appointment[]>();
for (const a of appointments) {
const key = a.start_at.slice(0, 10);
const group = groups.get(key) || [];
group.push(a);
groups.set(key, group);
}
return groups;
}
function formatDateLabel(dateStr: string): string {
const d = parseISO(dateStr);
if (isToday(d)) return "Heute";
if (isTomorrow(d)) return "Morgen";
return format(d, "EEEE, d. MMMM yyyy", { locale: de });
}
export function AppointmentList({ onEdit }: AppointmentListProps) {
const queryClient = useQueryClient();
const [caseFilter, setCaseFilter] = useState("all");
const [typeFilter, setTypeFilter] = useState("all");
const { data: appointments, isLoading } = useQuery({
queryKey: ["appointments"],
queryFn: () => api.get<Appointment[]>("/api/appointments"),
});
const { data: cases } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/api/appointments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
toast.success("Termin geloscht");
},
onError: () => toast.error("Fehler beim Loschen"),
});
const caseMap = useMemo(() => {
const map = new Map<string, Case>();
cases?.cases?.forEach((c) => map.set(c.id, c));
return map;
}, [cases]);
const filtered = useMemo(() => {
if (!appointments) return [];
return appointments
.filter((a) => {
if (caseFilter !== "all" && a.case_id !== caseFilter) return false;
if (typeFilter !== "all" && a.appointment_type !== typeFilter) return false;
return true;
})
.sort((a, b) => a.start_at.localeCompare(b.start_at));
}, [appointments, caseFilter, typeFilter]);
const grouped = useMemo(() => groupByDate(filtered), [filtered]);
const counts = useMemo(() => {
if (!appointments) return { today: 0, thisWeek: 0, total: 0 };
let today = 0;
let thisWeek = 0;
for (const a of appointments) {
const d = parseISO(a.start_at);
if (isToday(d)) today++;
if (isThisWeek(d, { weekStartsOn: 1 })) thisWeek++;
}
return { today, thisWeek, total: appointments.length };
}, [appointments]);
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-16 animate-pulse rounded-lg bg-neutral-100" />
))}
</div>
);
}
return (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border border-neutral-200 bg-white p-3">
<div className="text-2xl font-semibold text-neutral-900">{counts.today}</div>
<div className="text-xs text-neutral-500">Heute</div>
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-3">
<div className="text-2xl font-semibold text-neutral-900">{counts.thisWeek}</div>
<div className="text-xs text-neutral-500">Diese Woche</div>
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-3">
<div className="text-2xl font-semibold text-neutral-900">{counts.total}</div>
<div className="text-xs text-neutral-500">Gesamt</div>
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 text-sm text-neutral-500">
<Filter className="h-3.5 w-3.5" />
<span>Filter:</span>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700"
>
<option value="all">Alle Typen</option>
{Object.entries(TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{cases?.cases && cases.cases.length > 0 && (
<select
value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)}
className="rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700"
>
<option value="all">Alle Akten</option>
{cases.cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
)}
</div>
{/* Grouped list */}
{filtered.length === 0 ? (
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center">
<Calendar className="mx-auto h-8 w-8 text-neutral-300" />
<p className="mt-2 text-sm text-neutral-500">Keine Termine gefunden</p>
</div>
) : (
<div className="space-y-4">
{Array.from(grouped.entries()).map(([dateKey, dayAppointments]) => {
const dateIsPast = isPast(parseISO(dateKey + "T23:59:59"));
return (
<div key={dateKey}>
<div className={`mb-2 text-xs font-medium uppercase tracking-wider ${dateIsPast ? "text-neutral-400" : "text-neutral-600"}`}>
{formatDateLabel(dateKey)}
</div>
<div className="space-y-1.5">
{dayAppointments.map((appt) => {
const caseInfo = appt.case_id ? caseMap.get(appt.case_id) : null;
const typeBadge = appt.appointment_type
? TYPE_COLORS[appt.appointment_type] ?? TYPE_COLORS.other
: null;
const typeLabel = appt.appointment_type
? TYPE_LABELS[appt.appointment_type] ?? appt.appointment_type
: null;
return (
<div
key={appt.id}
onClick={() => onEdit(appt)}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-colors hover:bg-neutral-50 ${
dateIsPast
? "border-neutral-150 bg-neutral-50/50"
: "border-neutral-200 bg-white"
}`}
>
<div className="shrink-0 pt-0.5 text-center">
<div className="text-xs font-medium text-neutral-900">
{format(parseISO(appt.start_at), "HH:mm")}
</div>
{appt.end_at && (
<div className="text-[10px] text-neutral-400">
{format(parseISO(appt.end_at), "HH:mm")}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`truncate text-sm font-medium ${dateIsPast ? "text-neutral-500" : "text-neutral-900"}`}>
{appt.title}
</span>
{typeBadge && typeLabel && (
<span className={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${typeBadge}`}>
{typeLabel}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
{appt.location && (
<span className="flex items-center gap-0.5">
<MapPin className="h-3 w-3" />
{appt.location}
</span>
)}
{appt.location && caseInfo && <span>·</span>}
{caseInfo && (
<span className="truncate">
{caseInfo.case_number} {caseInfo.title}
</span>
)}
</div>
{appt.description && (
<p className="mt-1 truncate text-xs text-neutral-400">
{appt.description}
</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(appt.id);
}}
disabled={deleteMutation.isPending}
title="Loschen"
className="shrink-0 rounded-md p-1.5 text-neutral-300 hover:bg-red-50 hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,280 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Appointment, Case } from "@/lib/types";
import { format, parseISO } from "date-fns";
import { X } from "lucide-react";
import { toast } from "sonner";
import { useEffect, useState } from "react";
const APPOINTMENT_TYPES = [
{ value: "hearing", label: "Verhandlung" },
{ value: "meeting", label: "Besprechung" },
{ value: "consultation", label: "Beratung" },
{ value: "deadline_hearing", label: "Fristanhorung" },
{ value: "other", label: "Sonstiges" },
];
interface AppointmentModalProps {
open: boolean;
onClose: () => void;
appointment?: Appointment | null;
}
function toLocalDatetime(iso: string): string {
const d = parseISO(iso);
return format(d, "yyyy-MM-dd'T'HH:mm");
}
export function AppointmentModal({ open, onClose, appointment }: AppointmentModalProps) {
const queryClient = useQueryClient();
const isEdit = !!appointment;
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startAt, setStartAt] = useState("");
const [endAt, setEndAt] = useState("");
const [location, setLocation] = useState("");
const [appointmentType, setAppointmentType] = useState("");
const [caseId, setCaseId] = useState("");
const { data: cases } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
});
useEffect(() => {
if (appointment) {
setTitle(appointment.title);
setDescription(appointment.description ?? "");
setStartAt(toLocalDatetime(appointment.start_at));
setEndAt(appointment.end_at ? toLocalDatetime(appointment.end_at) : "");
setLocation(appointment.location ?? "");
setAppointmentType(appointment.appointment_type ?? "");
setCaseId(appointment.case_id ?? "");
} else {
setTitle("");
setDescription("");
setStartAt("");
setEndAt("");
setLocation("");
setAppointmentType("");
setCaseId("");
}
}, [appointment]);
const createMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post<Appointment>("/api/appointments", body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success("Termin erstellt");
onClose();
},
onError: () => toast.error("Fehler beim Erstellen des Termins"),
});
const updateMutation = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.put<Appointment>(`/api/appointments/${appointment!.id}`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success("Termin aktualisiert");
onClose();
},
onError: () => toast.error("Fehler beim Aktualisieren des Termins"),
});
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/appointments/${appointment!.id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
toast.success("Termin geloscht");
onClose();
},
onError: () => toast.error("Fehler beim Loschen des Termins"),
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !startAt) return;
const body: Record<string, unknown> = {
title: title.trim(),
start_at: new Date(startAt).toISOString(),
};
if (description.trim()) body.description = description.trim();
if (endAt) body.end_at = new Date(endAt).toISOString();
if (location.trim()) body.location = location.trim();
if (appointmentType) body.appointment_type = appointmentType;
if (caseId) body.case_id = caseId;
if (isEdit) {
updateMutation.mutate(body);
} else {
createMutation.mutate(body);
}
}
const isPending = createMutation.isPending || updateMutation.isPending;
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-full max-w-lg rounded-lg border border-neutral-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3">
<h2 className="text-sm font-semibold text-neutral-900">
{isEdit ? "Termin bearbeiten" : "Neuer Termin"}
</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
placeholder="z.B. Mundliche Verhandlung"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Beginn *
</label>
<input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
required
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Ende
</label>
<input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Typ
</label>
<select
value={appointmentType}
onChange={(e) => setAppointmentType(e.target.value)}
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400"
>
<option value="">Kein Typ</option>
{APPOINTMENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Akte
</label>
<select
value={caseId}
onChange={(e) => setCaseId(e.target.value)}
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400"
>
<option value="">Keine Akte</option>
{cases?.cases?.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Ort
</label>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
placeholder="z.B. UPC Munchen, Saal 3"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-600">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
placeholder="Optionale Notizen zum Termin"
/>
</div>
<div className="flex items-center justify-between pt-2">
<div>
{isEdit && (
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="rounded-md px-3 py-1.5 text-sm text-red-600 hover:bg-red-50"
>
Loschen
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={isPending || !title.trim() || !startAt}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{isPending ? "Speichern..." : isEdit ? "Aktualisieren" : "Erstellen"}
</button>
</div>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
const TYPE_OPTIONS = [
{ value: "", label: "-- Typ wählen --" },
{ value: "INF", label: "Verletzungsklage (INF)" },
{ value: "REV", label: "Widerruf (REV)" },
{ value: "CCR", label: "Einstweilige Verfügung (CCR)" },
{ value: "APP", label: "Berufung (APP)" },
{ value: "PI", label: "Vorläufiger Rechtsschutz (PI)" },
{ value: "ZPO_CIVIL", label: "ZPO Zivilverfahren" },
];
export interface CaseFormData {
case_number: string;
title: string;
case_type?: string;
court?: string;
court_ref?: string;
status: string;
}
interface CaseFormProps {
initialData?: Partial<CaseFormData>;
onSubmit: (data: CaseFormData) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function CaseForm({
initialData,
onSubmit,
isSubmitting,
submitLabel = "Akte anlegen",
}: CaseFormProps) {
const [form, setForm] = useState<CaseFormData>({
case_number: initialData?.case_number ?? "",
title: initialData?.title ?? "",
case_type: initialData?.case_type ?? "",
court: initialData?.court ?? "",
court_ref: initialData?.court_ref ?? "",
status: initialData?.status ?? "active",
});
const [errors, setErrors] = useState<Partial<Record<keyof CaseFormData, string>>>({});
function validate(): boolean {
const newErrors: Partial<Record<keyof CaseFormData, string>> = {};
if (!form.case_number.trim()) {
newErrors.case_number = "Aktenzeichen ist erforderlich";
}
if (!form.title.trim()) {
newErrors.title = "Titel ist erforderlich";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const data: CaseFormData = {
...form,
case_type: form.case_type || undefined,
court: form.court || undefined,
court_ref: form.court_ref || undefined,
};
onSubmit(data);
}
function update(field: keyof CaseFormData, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
}
const inputClass =
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Aktenzeichen *
</label>
<input
type="text"
value={form.case_number}
onChange={(e) => update("case_number", e.target.value)}
placeholder="z.B. 2026/001"
className={`${inputClass} ${errors.case_number ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`}
/>
{errors.case_number && (
<p className="mt-1 text-xs text-red-600">{errors.case_number}</p>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Status
</label>
<select
value={form.status}
onChange={(e) => update("status", e.target.value)}
className={inputClass}
>
<option value="active">Aktiv</option>
<option value="pending">Anhängig</option>
<option value="closed">Geschlossen</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Titel *
</label>
<input
type="text"
value={form.title}
onChange={(e) => update("title", e.target.value)}
placeholder="Bezeichnung der Akte"
className={`${inputClass} ${errors.title ? "border-red-300 focus:border-red-400 focus:ring-red-400" : ""}`}
/>
{errors.title && (
<p className="mt-1 text-xs text-red-600">{errors.title}</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Verfahrensart
</label>
<select
value={form.case_type}
onChange={(e) => update("case_type", e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Gericht
</label>
<input
type="text"
value={form.court}
onChange={(e) => update("court", e.target.value)}
placeholder="z.B. UPC München Zentralkammer"
className={inputClass}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-neutral-700">
Gerichtliches Aktenzeichen
</label>
<input
type="text"
value={form.court_ref}
onChange={(e) => update("court_ref", e.target.value)}
placeholder="z.B. UPC_CFI_123/2026"
className={inputClass}
/>
</div>
<div className="flex justify-end pt-2">
<button
type="submit"
disabled={isSubmitting}
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{isSubmitting ? "Speichern..." : submitLabel}
</button>
</div>
</form>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import type { CaseEvent } from "@/lib/types";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Activity } from "lucide-react";
const EVENT_ICONS: Record<string, string> = {
case_created: "bg-emerald-500",
status_changed: "bg-amber-500",
party_added: "bg-blue-500",
case_archived: "bg-neutral-400",
document_uploaded: "bg-violet-500",
deadline_created: "bg-red-500",
};
interface CaseTimelineProps {
events: CaseEvent[];
}
export function CaseTimeline({ events }: CaseTimelineProps) {
if (events.length === 0) {
return (
<div className="flex flex-col items-center py-8 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Activity className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Ereignisse vorhanden.
</p>
</div>
);
}
return (
<div className="relative space-y-0">
{events.map((event, i) => (
<div key={event.id} className="relative flex gap-3 pb-6">
{i < events.length - 1 && (
<div className="absolute left-[7px] top-4 h-full w-px bg-neutral-200" />
)}
<div
className={`mt-1 h-[15px] w-[15px] shrink-0 rounded-full border-2 border-white ${EVENT_ICONS[event.event_type ?? ""] ?? "bg-neutral-300"}`}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-neutral-900">
{event.title}
</p>
{event.description && (
<p className="mt-0.5 text-sm text-neutral-500">
{event.description}
</p>
)}
<p className="mt-1 text-xs text-neutral-400">
{format(
new Date(event.event_date ?? event.created_at),
"d. MMM yyyy, HH:mm",
{ locale: de },
)}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Party } from "@/lib/types";
import { Plus, Trash2, X, Users } from "lucide-react";
interface PartyListProps {
caseId: string;
parties: Party[];
}
interface PartyFormData {
name: string;
role: string;
representative: string;
}
const ROLE_OPTIONS = [
"Kläger",
"Beklagter",
"Nebenintervenient",
"Patentinhaber",
"Streithelfer",
];
const inputClass =
"w-full rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
export function PartyList({ caseId, parties }: PartyListProps) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<PartyFormData>({
name: "",
role: "",
representative: "",
});
const addMutation = useMutation({
mutationFn: (data: PartyFormData) =>
api.post<Party>(`/cases/${caseId}/parties`, {
name: data.name,
role: data.role || undefined,
representative: data.representative || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Partei hinzugefügt");
setShowForm(false);
setForm({ name: "", role: "", representative: "" });
},
onError: () => toast.error("Fehler beim Hinzufügen"),
});
const deleteMutation = useMutation({
mutationFn: (partyId: string) => api.delete(`/parties/${partyId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Partei entfernt");
},
onError: () => toast.error("Fehler beim Entfernen"),
});
return (
<div>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-700">
Parteien ({parties.length})
</h3>
{!showForm && (
<button
onClick={() => setShowForm(true)}
className="inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Hinzufügen
</button>
)}
</div>
{parties.length === 0 && !showForm && (
<div className="mt-4 flex flex-col items-center py-6 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Users className="h-5 w-5 text-neutral-400" />
</div>
<p className="mt-2 text-sm text-neutral-500">
Keine Parteien vorhanden.
</p>
<button
onClick={() => setShowForm(true)}
className="mt-3 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
>
<Plus className="h-3.5 w-3.5" />
Erste Partei hinzufügen
</button>
</div>
)}
<div className="mt-3 space-y-2">
{parties.map((party) => (
<div
key={party.id}
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-2.5 transition-colors hover:bg-neutral-50"
>
<div>
<p className="text-sm font-medium text-neutral-900">
{party.name}
</p>
<div className="mt-0.5 flex gap-3 text-xs text-neutral-500">
{party.role && <span>{party.role}</span>}
{party.representative && (
<span>Vertreter: {party.representative}</span>
)}
</div>
</div>
<button
onClick={() => deleteMutation.mutate(party.id)}
className="rounded p-1 text-neutral-300 transition-colors hover:bg-neutral-100 hover:text-red-500"
title="Partei entfernen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{showForm && (
<div className="mt-3 rounded-md border border-neutral-200 bg-neutral-50 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-neutral-700">
Neue Partei
</span>
<button
onClick={() => setShowForm(false)}
className="text-neutral-400 transition-colors hover:text-neutral-600"
>
<X className="h-4 w-4" />
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
if (!form.name.trim()) {
toast.error("Bitte Namen eingeben");
return;
}
addMutation.mutate(form);
}}
className="mt-3 space-y-3"
>
<input
type="text"
placeholder="Name der Partei"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className={inputClass}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
className={inputClass}
>
<option value="">-- Rolle --</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
type="text"
placeholder="Vertreter / Anwalt"
value={form.representative}
onChange={(e) =>
setForm({ ...form, representative: e.target.value })
}
className={inputClass}
/>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={addMutation.isPending}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:opacity-50"
>
{addMutation.isPending ? "Speichern..." : "Hinzufügen"}
</button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { Sparkles } from "lucide-react";
import type { DashboardData } from "@/lib/types";
interface Props {
data: DashboardData;
}
function generateSummary(data: DashboardData): string {
const parts: string[] = [];
const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data;
// Deadline urgency
if (ds.overdue_count > 0) {
parts.push(
`${ds.overdue_count} Frist${ds.overdue_count > 1 ? "en" : ""} ${ds.overdue_count > 1 ? "sind" : "ist"} überfällig und ${ds.overdue_count > 1 ? "erfordern" : "erfordert"} sofortige Aufmerksamkeit.`,
);
}
if (ds.due_this_week > 0) {
parts.push(
`${ds.due_this_week} Frist${ds.due_this_week > 1 ? "en laufen" : " läuft"} diese Woche ab.`,
);
}
// Highlight most critical upcoming deadline
if (ud.length > 0) {
const next = ud[0];
parts.push(
`Die nächste Frist ist "${next.title}" in Akte ${next.case_number}.`,
);
}
// Case activity
if (cs.new_this_month > 0) {
parts.push(
`${cs.new_this_month} neue Akte${cs.new_this_month > 1 ? "n" : ""} diesen Monat bei ${cs.active_count} aktiven Verfahren.`,
);
} else {
parts.push(`${cs.active_count} aktive Verfahren.`);
}
// All good
if (ds.overdue_count === 0 && ds.due_this_week === 0) {
parts.unshift("Alle Fristen sind im Zeitplan.");
}
return parts.join(" ");
}
export function AISummaryCard({ data }: Props) {
const summary = generateSummary(data);
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<div className="flex items-center gap-2">
<div className="rounded-md bg-violet-50 p-1.5">
<Sparkles className="h-4 w-4 text-violet-500" />
</div>
<h2 className="text-sm font-semibold text-neutral-900">
KI-Zusammenfassung
</h2>
</div>
<p className="mt-3 text-sm leading-relaxed text-neutral-700">
{summary}
</p>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { FolderOpen, FolderPlus, Archive } from "lucide-react";
import type { CaseSummary } from "@/lib/types";
interface Props {
data: CaseSummary;
}
export function CaseOverviewGrid({ data }: Props) {
const items = [
{
label: "Aktive Akten",
value: data.active_count,
icon: FolderOpen,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
label: "Neu (Monat)",
value: data.new_this_month,
icon: FolderPlus,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
label: "Abgeschlossen",
value: data.closed_count,
icon: Archive,
color: "text-neutral-500",
bg: "bg-neutral-50",
},
];
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">Aktenübersicht</h2>
<div className="mt-4 space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className={`rounded-md p-1.5 ${item.bg}`}>
<item.icon className={`h-4 w-4 ${item.color}`} />
</div>
<span className="text-sm text-neutral-600">{item.label}</span>
</div>
<span className="text-lg font-semibold tabular-nums text-neutral-900">
{item.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { AlertTriangle, Clock, CheckCircle } from "lucide-react";
import type { DeadlineSummary } from "@/lib/types";
function AnimatedCount({ value }: { value: number }) {
const ref = useRef<HTMLSpanElement>(null);
const prevValue = useRef(value);
useEffect(() => {
const el = ref.current;
if (!el || prevValue.current === value) return;
el.classList.remove("animate-count-up");
void el.offsetWidth;
el.classList.add("animate-count-up");
prevValue.current = value;
}, [value]);
return (
<span ref={ref} className="inline-block tabular-nums">
{value}
</span>
);
}
interface Props {
data: DeadlineSummary;
onFilter?: (filter: "overdue" | "this_week" | "ok") => void;
}
export function DeadlineTrafficLights({ data, onFilter }: Props) {
const cards = [
{
key: "overdue" as const,
label: "Überfällig",
count: data.overdue_count,
icon: AlertTriangle,
bg: "bg-red-50",
border: "border-red-200",
iconColor: "text-red-500",
countColor: "text-red-700",
labelColor: "text-red-600",
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
pulse: data.overdue_count > 0,
},
{
key: "this_week" as const,
label: "Diese Woche",
count: data.due_this_week,
icon: Clock,
bg: "bg-amber-50",
border: "border-amber-200",
iconColor: "text-amber-500",
countColor: "text-amber-700",
labelColor: "text-amber-600",
ring: "",
pulse: false,
},
{
key: "ok" as const,
label: "Im Zeitplan",
count: data.ok_count + data.due_next_week,
icon: CheckCircle,
bg: "bg-emerald-50",
border: "border-emerald-200",
iconColor: "text-emerald-500",
countColor: "text-emerald-700",
labelColor: "text-emerald-600",
ring: "",
pulse: false,
},
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{cards.map((card) => (
<button
key={card.key}
onClick={() => onFilter?.(card.key)}
className={`group relative overflow-hidden rounded-xl border ${card.border} ${card.bg} ${card.ring} p-6 text-left transition-all hover:shadow-md active:scale-[0.98]`}
>
{card.pulse && (
<span className="absolute right-4 top-4 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500" />
</span>
)}
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${card.bg}`}>
<card.icon className={`h-5 w-5 ${card.iconColor}`} />
</div>
<span className={`text-sm font-medium ${card.labelColor}`}>
{card.label}
</span>
</div>
<div className={`mt-4 text-4xl font-bold tracking-tight ${card.countColor}`}>
<AnimatedCount value={card.count} />
</div>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { FolderPlus, Clock, Sparkles, CalendarSync } from "lucide-react";
const actions = [
{
label: "Neue Akte",
href: "/cases/new",
icon: FolderPlus,
color: "text-blue-600 bg-blue-50 hover:bg-blue-100",
},
{
label: "Frist eintragen",
href: "/fristen",
icon: Clock,
color: "text-amber-600 bg-amber-50 hover:bg-amber-100",
},
{
label: "AI Analyse",
href: "/ai/extract",
icon: Sparkles,
color: "text-violet-600 bg-violet-50 hover:bg-violet-100",
},
{
label: "CalDAV Sync",
href: "/einstellungen",
icon: CalendarSync,
color: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100",
},
];
export function QuickActions() {
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">
Schnellzugriff
</h2>
<div className="mt-3 grid grid-cols-2 gap-2">
{actions.map((action) => (
<Link
key={action.label}
href={action.href}
className={`flex items-center gap-2 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${action.color}`}
>
<action.icon className="h-4 w-4" />
{action.label}
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { de } from "date-fns/locale";
import { Clock, Calendar, MapPin } from "lucide-react";
import type { UpcomingDeadline, UpcomingAppointment } from "@/lib/types";
interface Props {
deadlines: UpcomingDeadline[];
appointments: UpcomingAppointment[];
}
type TimelineItem =
| { type: "deadline"; date: Date; data: UpcomingDeadline }
| { type: "appointment"; date: Date; data: UpcomingAppointment };
function formatDayLabel(date: Date): string {
if (isToday(date)) return "Heute";
if (isTomorrow(date)) return "Morgen";
return format(date, "EEEE, d. MMM", { locale: de });
}
export function UpcomingTimeline({ deadlines, appointments }: Props) {
const items: TimelineItem[] = [
...deadlines.map((d) => ({
type: "deadline" as const,
date: parseISO(d.due_date),
data: d,
})),
...appointments.map((a) => ({
type: "appointment" as const,
date: parseISO(a.start_at),
data: a,
})),
].sort((a, b) => a.date.getTime() - b.date.getTime());
// Group by day
const grouped = new Map<string, TimelineItem[]>();
for (const item of items) {
const key = format(item.date, "yyyy-MM-dd");
const group = grouped.get(key) ?? [];
group.push(item);
grouped.set(key, group);
}
const empty = items.length === 0;
return (
<div className="rounded-xl border border-neutral-200 bg-white p-5">
<h2 className="text-sm font-semibold text-neutral-900">
Nächste 7 Tage
</h2>
{empty ? (
<p className="mt-6 text-center text-sm text-neutral-400">
Keine anstehenden Termine oder Fristen
</p>
) : (
<div className="mt-4 space-y-5">
{Array.from(grouped.entries()).map(([dateKey, dayItems]) => (
<div key={dateKey}>
<p className="text-xs font-medium uppercase tracking-wider text-neutral-400">
{formatDayLabel(dayItems[0].date)}
</p>
<div className="mt-2 space-y-2">
{dayItems.map((item, i) => (
<TimelineEntry key={`${item.type}-${i}`} item={item} />
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TimelineEntry({ item }: { item: TimelineItem }) {
if (item.type === "deadline") {
const d = item.data;
return (
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
<div className="mt-0.5 rounded-md bg-amber-50 p-1">
<Clock className="h-3.5 w-3.5 text-amber-500" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{d.title}
</p>
<p className="mt-0.5 truncate text-xs text-neutral-500">
{d.case_number} · {d.case_title}
</p>
</div>
<span className="shrink-0 text-xs font-medium text-amber-600">
Frist
</span>
</div>
);
}
const a = item.data;
return (
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
<div className="mt-0.5 rounded-md bg-blue-50 p-1">
<Calendar className="h-3.5 w-3.5 text-blue-500" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">
{a.title}
</p>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span>{format(item.date, "HH:mm")} Uhr</span>
{a.location && (
<>
<span className="text-neutral-300">·</span>
<span className="flex items-center gap-0.5 truncate">
<MapPin className="h-3 w-3" />
{a.location}
</span>
</>
)}
{a.case_number && (
<>
<span className="text-neutral-300">·</span>
<span>{a.case_number}</span>
</>
)}
</div>
</div>
<span className="shrink-0 text-xs font-medium text-blue-600">
Termin
</span>
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useQuery, useMutation } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type {
ProceedingType,
CalculateResponse,
CalculatedDeadline,
} from "@/lib/types";
import { format, parseISO, isPast, isThisWeek } from "date-fns";
import { de } from "date-fns/locale";
import {
Calculator,
Calendar,
ArrowRight,
AlertTriangle,
} from "lucide-react";
import { useState } from "react";
function getTimelineUrgency(dueDate: string): "red" | "amber" | "green" {
const due = parseISO(dueDate);
if (isPast(due)) return "red";
if (isThisWeek(due, { weekStartsOn: 1 })) return "amber";
return "green";
}
const dotColors = {
red: "bg-red-500",
amber: "bg-amber-500",
green: "bg-green-500",
};
const inputClass =
"w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 outline-none transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400";
export function DeadlineCalculator() {
const [proceedingType, setProceedingType] = useState("");
const [triggerDate, setTriggerDate] = useState("");
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/api/proceeding-types"),
});
const calculateMutation = useMutation({
mutationFn: (params: {
proceeding_type: string;
trigger_event_date: string;
}) => api.post<CalculateResponse>("/api/deadlines/calculate", params),
});
function handleCalculate(e: React.FormEvent) {
e.preventDefault();
if (!proceedingType || !triggerDate) return;
calculateMutation.mutate({
proceeding_type: proceedingType,
trigger_event_date: triggerDate,
});
}
const results = calculateMutation.data;
return (
<div className="space-y-6">
{/* Input form */}
<form
onSubmit={handleCalculate}
className="rounded-lg border border-neutral-200 bg-white p-5"
>
<div className="flex items-center gap-2 text-sm font-medium text-neutral-900">
<Calculator className="h-4 w-4" />
Fristenberechnung
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Verfahrensart
</label>
<select
value={proceedingType}
onChange={(e) => setProceedingType(e.target.value)}
disabled={typesLoading}
className={inputClass}
>
<option value="">Bitte wählen...</option>
{proceedingTypes?.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500">
Auslösedatum
</label>
<input
type="date"
value={triggerDate}
onChange={(e) => setTriggerDate(e.target.value)}
className={inputClass}
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={
!proceedingType ||
!triggerDate ||
calculateMutation.isPending
}
className="flex w-full items-center justify-center gap-2 rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{calculateMutation.isPending ? "Berechne..." : "Berechnen"}
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</div>
</form>
{/* Error */}
{calculateMutation.isError && (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
Fehler bei der Berechnung. Bitte Eingaben prüfen.
</div>
)}
{/* Results */}
{results && results.deadlines && (
<div className="animate-fade-in space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-neutral-900">
Berechnete Fristen
</h3>
<span className="text-xs text-neutral-500">
{results.deadlines.length} Fristen ab{" "}
{format(parseISO(results.trigger_event_date), "dd. MMM yyyy", {
locale: de,
})}
</span>
</div>
{/* Timeline */}
<div className="relative rounded-lg border border-neutral-200 bg-white">
{results.deadlines.map((d: CalculatedDeadline, i: number) => {
const urgency = getTimelineUrgency(d.due_date);
const isLast = i === results.deadlines.length - 1;
return (
<div
key={d.rule_id}
className={`flex gap-3 px-4 py-3 ${!isLast ? "border-b border-neutral-100" : ""}`}
>
<div className="flex flex-col items-center pt-1">
<div
className={`h-2.5 w-2.5 shrink-0 rounded-full ${dotColors[urgency]}`}
/>
{!isLast && (
<div className="mt-1 w-px flex-1 bg-neutral-200" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<span className="text-sm font-medium text-neutral-900">
{d.title}
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-neutral-700">
{format(parseISO(d.due_date), "dd.MM.yyyy")}
</span>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-500">
{d.rule_code && <span>{d.rule_code}</span>}
{d.was_adjusted && (
<>
{d.rule_code && <span>·</span>}
<span className="text-amber-600">
Angepasst (Original:{" "}
{format(
parseISO(d.original_due_date),
"dd.MM.yyyy",
)}
)
</span>
</>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Empty state */}
{!results && !calculateMutation.isPending && (
<div className="flex flex-col items-center rounded-lg border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Calendar className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm text-neutral-500">
Verfahrensart und Auslösedatum wählen, um Fristen zu berechnen
</p>
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More