Compare commits

..

24 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
74 changed files with 8048 additions and 50 deletions

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=

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

@@ -6,12 +6,13 @@ import (
)
type Config struct {
Port string
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
Port string
DatabaseURL string
SupabaseURL string
SupabaseAnonKey string
SupabaseServiceKey string
SupabaseJWTSecret string
AnthropicAPIKey string
AnthropicAPIKey string
}
func Load() (*Config, error) {
@@ -19,7 +20,8 @@ func Load() (*Config, error) {
Port: getEnv("PORT", "8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
SupabaseURL: os.Getenv("SUPABASE_URL"),
SupabaseAnonKey: os.Getenv("SUPABASE_ANON_KEY"),
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,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

@@ -39,6 +39,17 @@ func (h *DeadlineRuleHandlers) List(w http.ResponseWriter, r *http.Request) {
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) {

View File

@@ -20,6 +20,23 @@ func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHan
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)

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

@@ -83,3 +83,8 @@ func handleTenantError(w http.ResponseWriter, err error) {
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

@@ -196,6 +196,46 @@ func (h *TenantHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {
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())

View File

@@ -7,11 +7,12 @@ import (
"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"
)
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
@@ -23,6 +24,15 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
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)
@@ -38,6 +48,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
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))
@@ -49,6 +60,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
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)
@@ -70,6 +82,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
// 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)
@@ -79,6 +92,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
// 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)
@@ -92,8 +106,25 @@ func New(db *sqlx.DB, authMW *auth.Middleware) http.Handler {
// Dashboard
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
// Placeholder routes for future phases
scoped.HandleFunc("GET /api/documents", placeholder("documents"))
// 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))
@@ -115,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,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

@@ -21,6 +21,23 @@ 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,

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,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

@@ -3,6 +3,7 @@ package services
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
@@ -173,6 +174,21 @@ func (s *TenantService) InviteByEmail(ctx context.Context, tenantID uuid.UUID, e
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

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>
);
}

View File

@@ -0,0 +1,154 @@
"use client";
import type { Deadline } from "@/lib/types";
import {
format,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
eachDayOfInterval,
isSameMonth,
isToday,
parseISO,
isPast,
isThisWeek,
addMonths,
subMonths,
} from "date-fns";
import { de } from "date-fns/locale";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useState, useMemo } from "react";
interface DeadlineCalendarViewProps {
deadlines: Deadline[];
}
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
if (deadline.status === "completed") return "green";
const due = parseISO(deadline.due_date);
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",
};
export function DeadlineCalendarView({ deadlines }: DeadlineCalendarViewProps) {
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 deadlinesByDay = useMemo(() => {
const map = new Map<string, Deadline[]>();
for (const d of deadlines) {
if (d.status === "completed") continue;
const key = d.due_date.slice(0, 10);
const existing = map.get(key) || [];
existing.push(d);
map.set(key, existing);
}
return map;
}, [deadlines]);
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 dayDeadlines = deadlinesByDay.get(key) || [];
const inMonth = isSameMonth(day, currentMonth);
const today = isToday(day);
return (
<div
key={i}
className={`min-h-[4.5rem] border-b border-r border-neutral-100 p-1.5 ${
!inMonth ? "bg-neutral-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">
{dayDeadlines.slice(0, 3).map((dl) => {
const urgency = getUrgency(dl);
return (
<div
key={dl.id}
className="flex items-center gap-1 truncate"
title={dl.title}
>
<div className={`h-1.5 w-1.5 shrink-0 rounded-full ${dotColors[urgency]}`} />
<span className="truncate text-[10px] text-neutral-700">
{dl.title}
</span>
</div>
);
})}
{dayDeadlines.length > 3 && (
<div className="text-[10px] text-neutral-400">
+{dayDeadlines.length - 3} mehr
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,288 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { Deadline, Case } from "@/lib/types";
import { format, isPast, isThisWeek, parseISO } from "date-fns";
import { de } from "date-fns/locale";
import { Check, Clock, Filter } from "lucide-react";
import { toast } from "sonner";
import { useState, useMemo } from "react";
import { EmptyState } from "@/components/ui/EmptyState";
type StatusFilter = "all" | "pending" | "completed" | "overdue";
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
if (deadline.status === "completed") return "green";
const due = parseISO(deadline.due_date);
if (isPast(due)) return "red";
if (isThisWeek(due, { weekStartsOn: 1 })) return "amber";
return "green";
}
const urgencyConfig = {
red: {
bg: "bg-red-50",
border: "border-red-200",
badge: "bg-red-100 text-red-700",
dot: "bg-red-500",
label: "Überfällig",
},
amber: {
bg: "bg-amber-50",
border: "border-amber-200",
badge: "bg-amber-100 text-amber-700",
dot: "bg-amber-500",
label: "Diese Woche",
},
green: {
bg: "bg-white",
border: "border-neutral-200",
badge: "bg-green-100 text-green-700",
dot: "bg-green-500",
label: "OK",
},
};
const selectClass =
"rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700 transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 outline-none";
export function DeadlineList() {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [caseFilter, setCaseFilter] = useState<string>("all");
const { data: deadlines, isLoading } = useQuery({
queryKey: ["deadlines"],
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
});
const { data: cases } = useQuery({
queryKey: ["cases"],
queryFn: () => api.get<Case[]>("/api/cases"),
});
const completeMutation = useMutation({
mutationFn: (id: string) =>
api.patch<Deadline>(`/api/deadlines/${id}/complete`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
toast.success("Frist als erledigt markiert");
},
onError: () => {
toast.error("Fehler beim Abschließen der Frist");
},
});
const caseMap = useMemo(() => {
const map = new Map<string, Case>();
cases?.forEach((c) => map.set(c.id, c));
return map;
}, [cases]);
const filtered = useMemo(() => {
if (!deadlines) return [];
return deadlines.filter((d) => {
if (statusFilter === "pending" && d.status !== "pending") return false;
if (statusFilter === "completed" && d.status !== "completed")
return false;
if (statusFilter === "overdue") {
if (d.status === "completed") return false;
if (!isPast(parseISO(d.due_date))) return false;
}
if (caseFilter !== "all" && d.case_id !== caseFilter) return false;
return true;
});
}, [deadlines, statusFilter, caseFilter]);
const counts = useMemo(() => {
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 };
let overdue = 0,
thisWeek = 0,
ok = 0;
for (const d of deadlines) {
if (d.status === "completed") continue;
const urgency = getUrgency(d);
if (urgency === "red") overdue++;
else if (urgency === "amber") thisWeek++;
else ok++;
}
return { overdue, thisWeek, ok };
}, [deadlines]);
if (isLoading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].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">
<button
onClick={() =>
setStatusFilter(statusFilter === "overdue" ? "all" : "overdue")
}
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "overdue"
? "border-red-300 bg-red-50 ring-1 ring-red-200"
: "border-neutral-200 bg-white hover:bg-neutral-50"
}`}
>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{counts.overdue}
</div>
<div className="text-xs text-neutral-500">Überfällig</div>
</button>
<button
onClick={() =>
setStatusFilter(statusFilter === "pending" ? "all" : "pending")
}
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "pending"
? "border-amber-300 bg-amber-50 ring-1 ring-amber-200"
: "border-neutral-200 bg-white hover:bg-neutral-50"
}`}
>
<div className="text-2xl font-semibold tabular-nums text-amber-600">
{counts.thisWeek}
</div>
<div className="text-xs text-neutral-500">Diese Woche</div>
</button>
<button
onClick={() => setStatusFilter("all")}
className={`rounded-lg border p-3 text-left transition-all ${
statusFilter === "all"
? "border-green-300 bg-green-50 ring-1 ring-green-200"
: "border-neutral-200 bg-white hover:bg-neutral-50"
}`}
>
<div className="text-2xl font-semibold tabular-nums text-green-600">
{counts.ok}
</div>
<div className="text-xs text-neutral-500">OK</div>
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap 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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
className={selectClass}
>
<option value="all">Alle Status</option>
<option value="pending">Offen</option>
<option value="completed">Erledigt</option>
<option value="overdue">Überfällig</option>
</select>
{cases && cases.length > 0 && (
<select
value={caseFilter}
onChange={(e) => setCaseFilter(e.target.value)}
className={selectClass}
>
<option value="all">Alle Akten</option>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.case_number} {c.title}
</option>
))}
</select>
)}
</div>
{/* Deadline list */}
{filtered.length === 0 ? (
<EmptyState
icon={Clock}
title="Keine Fristen gefunden"
description={
statusFilter !== "all" || caseFilter !== "all"
? "Versuchen Sie andere Filtereinstellungen."
: "Es sind noch keine Fristen vorhanden."
}
/>
) : (
<div className="space-y-2">
{filtered.map((deadline) => {
const urgency = getUrgency(deadline);
const config = urgencyConfig[urgency];
const caseInfo = caseMap.get(deadline.case_id);
return (
<div
key={deadline.id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${config.bg} ${config.border}`}
>
<div
className={`h-2.5 w-2.5 shrink-0 rounded-full ${config.dot}`}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-neutral-900">
{deadline.title}
</span>
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${config.badge}`}
>
{config.label}
</span>
{deadline.status === "completed" && (
<span className="shrink-0 rounded bg-neutral-100 px-1.5 py-0.5 text-xs font-medium text-neutral-500">
Erledigt
</span>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<span>
{format(parseISO(deadline.due_date), "dd. MMM yyyy", {
locale: de,
})}
</span>
{caseInfo && (
<>
<span>·</span>
<span className="truncate">
{caseInfo.case_number} {caseInfo.title}
</span>
</>
)}
{deadline.source !== "manual" && (
<>
<span>·</span>
<span>{deadline.source}</span>
</>
)}
</div>
</div>
{deadline.status !== "completed" && (
<button
onClick={() => completeMutation.mutate(deadline.id)}
disabled={completeMutation.isPending}
title="Als erledigt markieren"
className="shrink-0 rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-white hover:text-green-600"
>
<Check className="h-4 w-4" />
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FileText, Download, Trash2, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Document } from "@/lib/types";
const DOC_TYPE_BADGE: Record<string, string> = {
schriftsatz: "bg-blue-50 text-blue-700",
beschluss: "bg-violet-50 text-violet-700",
urteil: "bg-emerald-50 text-emerald-700",
gutachten: "bg-amber-50 text-amber-700",
vertrag: "bg-cyan-50 text-cyan-700",
korrespondenz: "bg-neutral-100 text-neutral-600",
};
interface DocumentListProps {
documents: Document[];
caseId: string;
}
export function DocumentList({ documents, caseId }: DocumentListProps) {
const [deleteId, setDeleteId] = useState<string | null>(null);
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (docId: string) => api.delete(`/documents/${docId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] });
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
toast.success("Dokument geloescht");
setDeleteId(null);
},
onError: (err) => {
const msg =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: "Unbekannter Fehler";
toast.error(`Fehler beim Loeschen: ${msg}`);
setDeleteId(null);
},
});
if (documents.length === 0) {
return (
<p className="py-8 text-center text-sm text-neutral-400">
Keine Dokumente vorhanden.
</p>
);
}
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 min-w-0">
<FileText className="h-4 w-4 shrink-0 text-neutral-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-neutral-900">
{doc.title}
</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-neutral-400">
{doc.doc_type && (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
DOC_TYPE_BADGE[doc.doc_type.toLowerCase()] ??
"bg-neutral-100 text-neutral-600"
}`}
>
{doc.doc_type}
</span>
)}
{doc.file_size != null && (
<span>{formatFileSize(doc.file_size)}</span>
)}
<span>
{format(new Date(doc.created_at), "d. MMM yyyy", {
locale: de,
})}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 ml-3">
<a
href={`/api/documents/${doc.id}`}
className="rounded p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600"
title="Herunterladen"
>
<Download className="h-4 w-4" />
</a>
{deleteId === doc.id ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => deleteMutation.mutate(doc.id)}
disabled={deleteMutation.isPending}
className="rounded px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50"
>
{deleteMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
"Loeschen"
)}
</button>
<button
type="button"
onClick={() => setDeleteId(null)}
className="rounded px-2 py-1 text-xs text-neutral-500 hover:bg-neutral-100"
>
Abbrechen
</button>
</div>
) : (
<button
type="button"
onClick={() => setDeleteId(doc.id)}
className="rounded p-1.5 text-neutral-400 hover:bg-neutral-100 hover:text-red-500"
title="Loeschen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Upload, FileText, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { Document } from "@/lib/types";
interface DocumentUploadProps {
caseId: string;
}
export function DocumentUpload({ caseId }: DocumentUploadProps) {
const [files, setFiles] = useState<File[]>([]);
const queryClient = useQueryClient();
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
formData.append("title", file.name);
return api.postFormData<Document>(`/cases/${caseId}/documents`, formData);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] });
queryClient.invalidateQueries({ queryKey: ["case", caseId] });
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles((prev) => [...prev, ...acceptedFiles]);
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
disabled: uploadMutation.isPending,
});
function removeFile(index: number) {
setFiles((prev) => prev.filter((_, i) => i !== index));
}
async function handleUpload() {
if (files.length === 0) return;
let successCount = 0;
for (const file of files) {
try {
await uploadMutation.mutateAsync(file);
successCount++;
} catch (err) {
const msg =
err && typeof err === "object" && "error" in err
? (err as { error: string }).error
: file.name;
toast.error(`Fehler beim Hochladen: ${msg}`);
}
}
if (successCount > 0) {
toast.success(
successCount === 1
? "Dokument hochgeladen"
: `${successCount} Dokumente hochgeladen`,
);
setFiles([]);
}
}
return (
<div className="space-y-3">
<div
{...getRootProps()}
className={`cursor-pointer rounded-md border-2 border-dashed px-6 py-6 text-center transition-colors ${
isDragActive
? "border-neutral-500 bg-neutral-50"
: "border-neutral-300 hover:border-neutral-400"
} ${uploadMutation.isPending ? "pointer-events-none opacity-50" : ""}`}
>
<input {...getInputProps()} />
<Upload className="mx-auto h-6 w-6 text-neutral-400" />
<p className="mt-2 text-sm text-neutral-600">
Dateien hierher ziehen oder{" "}
<span className="font-medium text-neutral-900">durchsuchen</span>
</p>
<p className="mt-1 text-xs text-neutral-400">Max. 50 MB pro Datei</p>
</div>
{files.length > 0 && (
<div className="space-y-2">
{files.map((file, i) => (
<div
key={`${file.name}-${i}`}
className="flex items-center gap-3 rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2"
>
<FileText className="h-4 w-4 shrink-0 text-neutral-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-neutral-900">{file.name}</p>
<p className="text-xs text-neutral-400">
{formatFileSize(file.size)}
</p>
</div>
<button
type="button"
onClick={() => removeFile(i)}
disabled={uploadMutation.isPending}
className="rounded p-1 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
<button
type="button"
onClick={handleUpload}
disabled={uploadMutation.isPending}
className="inline-flex items-center gap-2 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{uploadMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Hochladen...
</>
) : (
<>
<Upload className="h-3.5 w-3.5" />
{files.length === 1 ? "Hochladen" : `${files.length} Dateien hochladen`}
</>
)}
</button>
</div>
)}
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,47 @@
"use client";
import { createClient } from "@/lib/supabase/client";
import { TenantSwitcher } from "./TenantSwitcher";
import { LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
export function Header() {
const [email, setEmail] = useState<string | null>(null);
const router = useRouter();
const supabase = createClient();
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setEmail(user?.email ?? null);
});
}, [supabase.auth]);
async function handleLogout() {
await supabase.auth.signOut();
router.push("/login");
router.refresh();
}
return (
<header className="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4">
{/* Spacer for mobile hamburger */}
<div className="w-8 lg:w-0" />
<div className="flex items-center gap-2 sm:gap-3">
<TenantSwitcher />
{email && (
<span className="hidden text-sm text-neutral-500 sm:inline">
{email}
</span>
)}
<button
onClick={handleLogout}
title="Abmelden"
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
FolderOpen,
Clock,
Calendar,
Brain,
Settings,
Menu,
X,
} from "lucide-react";
import { useState, useEffect } from "react";
const navigation = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Akten", href: "/cases", icon: FolderOpen },
{ name: "Fristen", href: "/fristen", icon: Clock },
{ name: "Termine", href: "/termine", icon: Calendar },
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
// Close on route change
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// Close on escape
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setMobileOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
const navContent = (
<>
<div className="flex h-14 items-center justify-between border-b border-neutral-200 px-4">
<span className="text-sm font-semibold text-neutral-900">KanzlAI</span>
<button
onClick={() => setMobileOpen(false)}
className="rounded-md p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 lg:hidden"
aria-label="Menü schließen"
>
<X className="h-4 w-4" />
</button>
</div>
<nav className="flex-1 space-y-0.5 p-2">
{navigation.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors ${
isActive
? "bg-neutral-100 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
}`}
>
<item.icon className="h-4 w-4 shrink-0" />
{item.name}
</Link>
);
})}
</nav>
</>
);
return (
<>
{/* Mobile hamburger button */}
<button
onClick={() => setMobileOpen(true)}
className="fixed left-3 top-3.5 z-40 rounded-md bg-white p-1.5 shadow-sm ring-1 ring-neutral-200 transition-colors hover:bg-neutral-50 lg:hidden"
aria-label="Menü öffnen"
>
<Menu className="h-5 w-5 text-neutral-700" />
</button>
{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Mobile sidebar */}
{mobileOpen && (
<aside className="animate-slide-in-left fixed inset-y-0 left-0 z-50 flex w-56 flex-col border-r border-neutral-200 bg-white shadow-lg lg:hidden">
{navContent}
</aside>
)}
{/* Desktop sidebar */}
<aside className="hidden h-full w-56 flex-col border-r border-neutral-200 bg-white lg:flex">
{navContent}
</aside>
</>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { api } from "@/lib/api";
import type { TenantWithRole } from "@/lib/types";
import { ChevronsUpDown } from "lucide-react";
import { useEffect, useRef, useState } from "react";
export function TenantSwitcher() {
const [tenants, setTenants] = useState<TenantWithRole[]>([]);
const [current, setCurrent] = useState<TenantWithRole | null>(null);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
api
.get<TenantWithRole[]>("/tenants")
.then((data) => {
setTenants(data);
const savedId = localStorage.getItem("kanzlai_tenant_id");
const match = data.find((t) => t.id === savedId) || data[0];
if (match) {
setCurrent(match);
localStorage.setItem("kanzlai_tenant_id", match.id);
}
})
.catch(() => {
// Not authenticated or no tenants
});
}, []);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function switchTenant(tenant: TenantWithRole) {
setCurrent(tenant);
localStorage.setItem("kanzlai_tenant_id", tenant.id);
setOpen(false);
window.location.reload();
}
if (!current) return null;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
>
<span className="max-w-[120px] truncate sm:max-w-[160px]">
{current.name}
</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
</button>
{open && tenants.length > 1 && (
<div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
{tenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => switchTenant(tenant)}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
tenant.id === current.id
? "bg-neutral-50 font-medium text-neutral-900"
: "text-neutral-600 hover:bg-neutral-50"
}`}
>
<span className="truncate">{tenant.name}</span>
<span className="ml-auto text-xs text-neutral-400">
{tenant.role}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,329 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
RefreshCw,
CheckCircle2,
AlertCircle,
Clock,
ArrowUpDown,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
Tenant,
CalDAVConfig,
CalDAVSyncResponse,
} from "@/lib/types";
const SYNC_INTERVALS = [
{ value: 5, label: "5 Minuten" },
{ value: 15, label: "15 Minuten" },
{ value: 30, label: "30 Minuten" },
{ value: 60, label: "1 Stunde" },
{ value: 120, label: "2 Stunden" },
{ value: 360, label: "6 Stunden" },
];
const emptyConfig: CalDAVConfig = {
url: "",
username: "",
password: "",
calendar_path: "",
sync_enabled: false,
sync_interval_minutes: 15,
};
export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
const queryClient = useQueryClient();
const existing = (tenant.settings as Record<string, unknown>)?.caldav as
| Partial<CalDAVConfig>
| undefined;
const [config, setConfig] = useState<CalDAVConfig>({
...emptyConfig,
...existing,
});
const [showPassword, setShowPassword] = useState(false);
// Reset form when tenant changes
useEffect(() => {
const caldav = (tenant.settings as Record<string, unknown>)?.caldav as
| Partial<CalDAVConfig>
| undefined;
setConfig({ ...emptyConfig, ...caldav });
}, [tenant]);
// Fetch sync status
const { data: syncStatus } = useQuery({
queryKey: ["caldav-status"],
queryFn: () => api.get<CalDAVSyncResponse>("/api/caldav/status"),
refetchInterval: 30_000,
});
// Save settings
const saveMutation = useMutation({
mutationFn: (cfg: CalDAVConfig) => {
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
return api.put<Tenant>(`/api/tenants/${tenantId}/settings`, {
caldav: cfg,
});
},
onSuccess: (updated) => {
queryClient.setQueryData(["tenant-current"], updated);
toast.success("CalDAV-Einstellungen gespeichert");
},
onError: () => {
toast.error("Fehler beim Speichern der CalDAV-Einstellungen");
},
});
// Trigger sync
const syncMutation = useMutation({
mutationFn: () => api.post<CalDAVSyncResponse>("/api/caldav/sync"),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ["caldav-status"] });
if (result.status === "ok") {
toast.success(
`Synchronisierung abgeschlossen: ${result.sync.items_pushed} gesendet, ${result.sync.items_pulled} empfangen`
);
} else {
toast.error("Synchronisierung mit Fehlern abgeschlossen");
}
},
onError: () => {
toast.error("Fehler bei der Synchronisierung");
},
});
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
saveMutation.mutate(config);
};
const hasConfig = config.url && config.username && config.password;
return (
<div className="space-y-6">
{/* CalDAV Configuration Form */}
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-neutral-700">
CalDAV-Server URL
</label>
<input
type="url"
value={config.url}
onChange={(e) =>
setConfig((c) => ({ ...c, url: e.target.value }))
}
placeholder="https://dav.example.com/dav"
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-sm font-medium text-neutral-700">
Benutzername
</label>
<input
type="text"
value={config.username}
onChange={(e) =>
setConfig((c) => ({ ...c, username: e.target.value }))
}
placeholder="user@example.com"
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-sm font-medium text-neutral-700">
Passwort
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={config.password}
onChange={(e) =>
setConfig((c) => ({ ...c, password: e.target.value }))
}
placeholder="••••••••"
className="w-full rounded-md border border-neutral-200 px-3 py-1.5 pr-16 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-neutral-500 hover:text-neutral-700"
>
{showPassword ? "Verbergen" : "Anzeigen"}
</button>
</div>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-neutral-700">
Kalender-Pfad
</label>
<input
type="text"
value={config.calendar_path}
onChange={(e) =>
setConfig((c) => ({ ...c, calendar_path: e.target.value }))
}
placeholder="/dav/calendars/user/default/"
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"
/>
<p className="mt-1 text-xs text-neutral-400">
Pfad zum Kalender auf dem CalDAV-Server
</p>
</div>
</div>
{/* Sync Settings */}
<div className="flex flex-col gap-4 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between">
<label className="flex items-center gap-2.5">
<input
type="checkbox"
checked={config.sync_enabled}
onChange={(e) =>
setConfig((c) => ({ ...c, sync_enabled: e.target.checked }))
}
className="h-4 w-4 rounded border-neutral-300 text-neutral-900 focus:ring-neutral-400"
/>
<span className="text-sm font-medium text-neutral-700">
Automatische Synchronisierung
</span>
</label>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-500">Intervall:</label>
<select
value={config.sync_interval_minutes}
onChange={(e) =>
setConfig((c) => ({
...c,
sync_interval_minutes: Number(e.target.value),
}))
}
disabled={!config.sync_enabled}
className="rounded-md border border-neutral-200 px-2 py-1 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 disabled:opacity-50"
>
{SYNC_INTERVALS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-3 border-t border-neutral-200 pt-4">
<button
type="submit"
disabled={saveMutation.isPending}
className="rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
{saveMutation.isPending ? "Speichern..." : "Speichern"}
</button>
{hasConfig && (
<button
type="button"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-4 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
<RefreshCw
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
/>
{syncMutation.isPending
? "Synchronisiere..."
: "Jetzt synchronisieren"}
</button>
)}
</div>
</form>
{/* Sync Status */}
{syncStatus && syncStatus.last_sync_at !== null && (
<SyncStatusDisplay data={syncStatus} />
)}
</div>
);
}
function SyncStatusDisplay({ data }: { data: CalDAVSyncResponse }) {
const hasErrors = data.sync?.errors && data.sync.errors.length > 0;
const lastSync = data.sync?.last_sync_at
? new Date(data.sync.last_sync_at)
: null;
return (
<div
className={`rounded-lg border p-4 ${
hasErrors
? "border-red-200 bg-red-50"
: "border-emerald-200 bg-emerald-50"
}`}
>
<div className="flex items-start gap-3">
{hasErrors ? (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
) : (
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
)}
<div className="min-w-0 flex-1">
<p
className={`text-sm font-medium ${hasErrors ? "text-red-800" : "text-emerald-800"}`}
>
{hasErrors
? "Letzte Synchronisierung mit Fehlern"
: "Letzte Synchronisierung erfolgreich"}
</p>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
{lastSync && (
<span className="inline-flex items-center gap-1 text-neutral-600">
<Clock className="h-3 w-3" />
{lastSync.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}{" "}
{lastSync.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
<span className="inline-flex items-center gap-1 text-neutral-600">
<ArrowUpDown className="h-3 w-3" />
{data.sync.items_pushed} gesendet, {data.sync.items_pulled}{" "}
empfangen
</span>
{data.sync.sync_duration && (
<span className="text-neutral-400">
Dauer: {data.sync.sync_duration}
</span>
)}
</div>
{hasErrors && (
<div className="mt-2 space-y-1">
{data.sync.errors!.map((err, i) => (
<p key={i} className="text-xs text-red-700">
{err}
</p>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
import { api } from "@/lib/api";
import type { UserTenant } from "@/lib/types";
import { Skeleton } from "@/components/ui/Skeleton";
import { EmptyState } from "@/components/ui/EmptyState";
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
owner: { label: "Eigentümer", icon: Crown },
admin: { label: "Administrator", icon: Shield },
member: { label: "Mitglied", icon: User },
};
export function TeamSettings() {
const queryClient = useQueryClient();
const tenantId =
typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
const [email, setEmail] = useState("");
const [role, setRole] = useState("member");
const {
data: members,
isLoading,
error,
} = useQuery({
queryKey: ["tenant-members", tenantId],
queryFn: () =>
api.get<UserTenant[]>(`/api/tenants/${tenantId}/members`),
enabled: !!tenantId,
});
const inviteMutation = useMutation({
mutationFn: (data: { email: string; role: string }) =>
api.post(`/api/tenants/${tenantId}/invite`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
setEmail("");
setRole("member");
toast.success("Benutzer eingeladen");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Einladen");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) =>
api.delete(`/api/tenants/${tenantId}/members/${userId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
toast.success("Mitglied entfernt");
},
onError: (err: { error?: string }) => {
toast.error(err.error || "Fehler beim Entfernen");
},
});
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim()) return;
inviteMutation.mutate({ email: email.trim(), role });
};
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
if (error) {
return (
<EmptyState
icon={User}
title="Fehler beim Laden"
description="Team-Mitglieder konnten nicht geladen werden."
/>
);
}
return (
<div className="space-y-6">
{/* Invite Form */}
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="flex-1 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"
/>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
>
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</select>
<button
type="submit"
disabled={inviteMutation.isPending || !email.trim()}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
>
<UserPlus className="h-3.5 w-3.5" />
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
</button>
</form>
{/* Members List */}
{members && members.length > 0 ? (
<div className="overflow-hidden rounded-md border border-neutral-200">
{members.map((member, i) => {
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
const RoleIcon = roleInfo.icon;
return (
<div
key={member.user_id}
className={`flex items-center justify-between px-4 py-3 ${
i < members.length - 1 ? "border-b border-neutral-100" : ""
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-100">
<RoleIcon className="h-4 w-4 text-neutral-500" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{member.user_id.slice(0, 8)}...
</p>
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
</div>
</div>
{member.role !== "owner" && (
<button
onClick={() => removeMutation.mutate(member.user_id)}
disabled={removeMutation.isPending}
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="Mitglied entfernen"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
);
})}
</div>
) : (
<EmptyState
icon={User}
title="Noch keine Mitglieder"
description="Laden Sie Teammitglieder per E-Mail ein."
/>
)}
</div>
);
}

View File

@@ -0,0 +1,28 @@
import type { LucideIcon } from "lucide-react";
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
}
export function EmptyState({
icon: Icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-neutral-300 bg-white px-6 py-12 text-center">
<div className="rounded-xl bg-neutral-100 p-3">
<Icon className="h-6 w-6 text-neutral-400" />
</div>
<h3 className="mt-3 text-sm font-medium text-neutral-900">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-neutral-500">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,43 @@
export function Skeleton({ className = "" }: { className?: string }) {
return (
<div
className={`animate-pulse rounded-md bg-neutral-200/60 ${className}`}
/>
);
}
export function SkeletonCard({ className = "" }: { className?: string }) {
return (
<div
className={`rounded-xl border border-neutral-200 bg-white p-5 ${className}`}
>
<Skeleton className="h-4 w-24" />
<div className="mt-4 space-y-3">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}
export function SkeletonTable({ rows = 5 }: { rows?: number }) {
return (
<div className="overflow-hidden rounded-md border border-neutral-200 bg-white">
<div className="border-b border-neutral-100 px-4 py-3">
<Skeleton className="h-3 w-full" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 border-b border-neutral-100 px-4 py-3 last:border-b-0"
>
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-12" />
</div>
))}
</div>
);
}

119
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,119 @@
import { createClient } from "@/lib/supabase/client";
import type { ApiError } from "@/lib/types";
class ApiClient {
private baseUrl = "/api";
private async getHeaders(): Promise<HeadersInit> {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const headers: HeadersInit = {
"Content-Type": "application/json",
};
if (session?.access_token) {
headers["Authorization"] = `Bearer ${session.access_token}`;
}
const tenantId = typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
return headers;
}
private async request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const headers = await this.getHeaders();
const res = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...headers, ...options.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err: ApiError = {
error: body.error || res.statusText,
status: res.status,
};
throw err;
}
if (res.status === 204) return undefined as T;
return res.json();
}
get<T>(path: string) {
return this.request<T>(path, { method: "GET" });
}
post<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
}
put<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "PUT",
body: body ? JSON.stringify(body) : undefined,
});
}
patch<T>(path: string, body?: unknown) {
return this.request<T>(path, {
method: "PATCH",
body: body ? JSON.stringify(body) : undefined,
});
}
delete<T>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
async postFormData<T>(path: string, formData: FormData): Promise<T> {
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const headers: HeadersInit = {};
if (session?.access_token) {
headers["Authorization"] = `Bearer ${session.access_token}`;
}
const tenantId = typeof window !== "undefined"
? localStorage.getItem("kanzlai_tenant_id")
: null;
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
const res = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const err: ApiError = {
error: body.error || res.statusText,
status: res.status,
};
throw err;
}
return res.json();
}
}
export const api = new ApiClient();

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}

View File

@@ -0,0 +1,29 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// setAll is called from Server Components where cookies
// cannot be set. This is safe to ignore when middleware
// handles the session refresh.
}
},
},
},
);
}

248
frontend/src/lib/types.ts Normal file
View File

@@ -0,0 +1,248 @@
export interface Tenant {
id: string;
name: string;
slug: string;
settings: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface TenantWithRole extends Tenant {
role: string;
}
export interface UserTenant {
user_id: string;
tenant_id: string;
role: string;
created_at: string;
}
export interface Case {
id: string;
tenant_id: string;
case_number: string;
title: string;
case_type?: string;
court?: string;
court_ref?: string;
status: string;
ai_summary?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Party {
id: string;
tenant_id: string;
case_id: string;
name: string;
role?: string;
representative?: string;
contact_info: Record<string, unknown>;
}
export interface Deadline {
id: string;
tenant_id: string;
case_id: string;
title: string;
description?: string;
due_date: string;
original_due_date?: string;
warning_date?: string;
source: string;
rule_id?: string;
status: string;
completed_at?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface Appointment {
id: string;
tenant_id: string;
case_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
created_at: string;
updated_at: string;
}
export interface CaseEvent {
id: string;
tenant_id: string;
case_id: string;
event_type?: string;
title: string;
description?: string;
event_date?: string;
created_by?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface Document {
id: string;
tenant_id: string;
case_id: string;
title: string;
doc_type?: string;
file_path?: string;
file_size?: number;
mime_type?: string;
ai_extracted?: Record<string, unknown>;
uploaded_by?: string;
created_at: string;
updated_at: string;
}
export interface DeadlineRule {
id: string;
proceeding_type_id?: number;
parent_id?: string;
code?: string;
name: string;
description?: string;
primary_party?: string;
event_type?: string;
is_mandatory: boolean;
duration_value: number;
duration_unit: string;
timing?: string;
rule_code?: string;
deadline_notes?: string;
sequence_order: number;
}
export interface RuleTreeNode extends DeadlineRule {
children?: RuleTreeNode[];
}
export interface ProceedingType {
id: number;
code: string;
name: string;
description?: string;
jurisdiction?: string;
default_color: string;
sort_order: number;
is_active: boolean;
}
export interface CalculatedDeadline {
rule_code: string;
rule_id: string;
title: string;
due_date: string;
original_due_date: string;
was_adjusted: boolean;
}
export interface CalculateResponse {
proceeding_type: string;
trigger_event_date: string;
deadlines: CalculatedDeadline[];
}
export interface CalDAVConfig {
url: string;
username: string;
password: string;
calendar_path: string;
sync_enabled: boolean;
sync_interval_minutes: number;
}
export interface CalDAVSyncStatus {
tenant_id: string;
last_sync_at: string;
items_pushed: number;
items_pulled: number;
errors?: string[];
sync_duration: string;
}
export interface CalDAVSyncResponse {
status: string;
sync: CalDAVSyncStatus;
last_sync_at?: null;
}
export interface ApiError {
error: string;
status: number;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
}
// Dashboard types
export interface DeadlineSummary {
overdue_count: number;
due_this_week: number;
due_next_week: number;
ok_count: number;
}
export interface CaseSummary {
active_count: number;
new_this_month: number;
closed_count: number;
}
export interface UpcomingDeadline {
id: string;
title: string;
due_date: string;
case_id: string;
case_number: string;
case_title: string;
}
export interface UpcomingAppointment {
id: string;
title: string;
start_at: string;
end_at?: string;
location?: string;
case_id?: string;
case_number?: string;
case_title?: string;
}
export interface DashboardData {
deadline_summary: DeadlineSummary;
case_summary: CaseSummary;
upcoming_deadlines: UpcomingDeadline[];
upcoming_appointments: UpcomingAppointment[];
}
// AI Extraction types
export interface ExtractedDeadline {
title: string;
due_date: string | null;
duration_value?: number;
duration_unit?: string;
rule_reference: string;
source_quote: string;
confidence: number;
}
export interface ExtractionResponse {
deadlines: ExtractedDeadline[];
count: number;
}

View File

@@ -0,0 +1,60 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value),
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
},
);
const {
data: { user },
} = await supabase.auth.getUser();
const { pathname } = request.nextUrl;
// Auth pages — redirect to app if already logged in
if (user && (pathname === "/login" || pathname === "/register")) {
const url = request.nextUrl.clone();
url.pathname = "/";
return NextResponse.redirect(url);
}
// Protected routes — redirect to login if not authenticated
if (
!user &&
!pathname.startsWith("/login") &&
!pathname.startsWith("/register") &&
!pathname.startsWith("/callback")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};