diff --git a/backend/internal/handlers/deadline_rules.go b/backend/internal/handlers/deadline_rules.go index 6c410b1..8de4ad2 100644 --- a/backend/internal/handlers/deadline_rules.go +++ b/backend/internal/handlers/deadline_rules.go @@ -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) { diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index 844d243..4efa20c 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -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) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 60945f4..af85f3a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -81,6 +81,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) 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) @@ -90,6 +91,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) 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) diff --git a/backend/internal/services/deadline_service.go b/backend/internal/services/deadline_service.go index 4923e13..61eebd4 100644 --- a/backend/internal/services/deadline_service.go +++ b/backend/internal/services/deadline_service.go @@ -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, diff --git a/frontend/src/app/(app)/fristen/page.tsx b/frontend/src/app/(app)/fristen/page.tsx new file mode 100644 index 0000000..830c60c --- /dev/null +++ b/frontend/src/app/(app)/fristen/page.tsx @@ -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("list"); + + const { data: deadlines } = useQuery({ + queryKey: ["deadlines"], + queryFn: () => api.get("/api/deadlines"), + }); + + return ( +
+
+
+

Fristen

+

+ Alle Fristen im Uberblick +

+
+
+ + + Fristenrechner + +
+ + +
+
+
+ + {view === "list" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/src/app/(app)/fristen/rechner/page.tsx b/frontend/src/app/(app)/fristen/rechner/page.tsx new file mode 100644 index 0000000..8d1850b --- /dev/null +++ b/frontend/src/app/(app)/fristen/rechner/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { DeadlineCalculator } from "@/components/deadlines/DeadlineCalculator"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export default function FristenrechnerPage() { + return ( +
+
+ + + Zuruck zu Fristen + +

Fristenrechner

+

+ Berechnen Sie Fristen basierend auf Verfahrensart und Auslosedatum +

+
+ +
+ ); +} diff --git a/frontend/src/components/deadlines/DeadlineCalculator.tsx b/frontend/src/components/deadlines/DeadlineCalculator.tsx new file mode 100644 index 0000000..21572e4 --- /dev/null +++ b/frontend/src/components/deadlines/DeadlineCalculator.tsx @@ -0,0 +1,178 @@ +"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", +}; + +export function DeadlineCalculator() { + const [proceedingType, setProceedingType] = useState(""); + const [triggerDate, setTriggerDate] = useState(""); + + const { data: proceedingTypes, isLoading: typesLoading } = useQuery({ + queryKey: ["proceeding-types"], + queryFn: () => api.get("/api/proceeding-types"), + }); + + const calculateMutation = useMutation({ + mutationFn: (params: { proceeding_type: string; trigger_event_date: string }) => + api.post("/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 ( +
+ {/* Input form */} +
+
+ + Fristenberechnung +
+
+
+ + +
+
+ + setTriggerDate(e.target.value)} + className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900" + /> +
+
+ +
+
+
+ + {/* Error */} + {calculateMutation.isError && ( +
+ + Fehler bei der Berechnung. Bitte Eingaben prufen. +
+ )} + + {/* Results */} + {results && results.deadlines && ( +
+
+

+ Berechnete Fristen +

+ + {results.deadlines.length} Fristen ab{" "} + {format(parseISO(results.trigger_event_date), "dd. MMM yyyy", { locale: de })} + +
+ + {/* Timeline */} +
+ {results.deadlines.map((d: CalculatedDeadline, i: number) => { + const urgency = getTimelineUrgency(d.due_date); + const isLast = i === results.deadlines.length - 1; + + return ( +
+ {/* Timeline dot + line */} +
+
+ {!isLast &&
} +
+ + {/* Content */} +
+
+ + {d.title} + + + {format(parseISO(d.due_date), "dd.MM.yyyy")} + +
+
+ {d.rule_code && {d.rule_code}} + {d.was_adjusted && ( + <> + {d.rule_code && ·} + + Angepasst (Original: {format(parseISO(d.original_due_date), "dd.MM.yyyy")}) + + + )} +
+
+
+ ); + })} +
+
+ )} + + {/* Empty state */} + {!results && !calculateMutation.isPending && ( +
+ +

+ Verfahrensart und Auslosedatum wahlen, um Fristen zu berechnen +

+
+ )} +
+ ); +} diff --git a/frontend/src/components/deadlines/DeadlineCalendarView.tsx b/frontend/src/components/deadlines/DeadlineCalendarView.tsx new file mode 100644 index 0000000..1780c37 --- /dev/null +++ b/frontend/src/components/deadlines/DeadlineCalendarView.tsx @@ -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(); + 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 ( +
+ {/* Header */} +
+ + + {format(currentMonth, "MMMM yyyy", { locale: de })} + + +
+ + {/* Weekday labels */} +
+ {weekDays.map((d) => ( +
+ {d} +
+ ))} +
+ + {/* Days grid */} +
+ {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 ( +
+
+ {today ? ( + + {format(day, "d")} + + ) : ( + format(day, "d") + )} +
+
+ {dayDeadlines.slice(0, 3).map((dl) => { + const urgency = getUrgency(dl); + return ( +
+
+ + {dl.title} + +
+ ); + })} + {dayDeadlines.length > 3 && ( +
+ +{dayDeadlines.length - 3} mehr +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/deadlines/DeadlineList.tsx b/frontend/src/components/deadlines/DeadlineList.tsx new file mode 100644 index 0000000..79adeb7 --- /dev/null +++ b/frontend/src/components/deadlines/DeadlineList.tsx @@ -0,0 +1,257 @@ +"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"; + +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: "Uberschritten", + }, + 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", + }, +}; + +export function DeadlineList() { + const queryClient = useQueryClient(); + const [statusFilter, setStatusFilter] = useState("all"); + const [caseFilter, setCaseFilter] = useState("all"); + + const { data: deadlines, isLoading } = useQuery({ + queryKey: ["deadlines"], + queryFn: () => api.get("/api/deadlines"), + }); + + const { data: cases } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get("/api/cases"), + }); + + const completeMutation = useMutation({ + mutationFn: (id: string) => + api.patch(`/api/deadlines/${id}/complete`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["deadlines"] }); + toast.success("Frist als erledigt markiert"); + }, + onError: () => { + toast.error("Fehler beim Abschliessen der Frist"); + }, + }); + + const caseMap = useMemo(() => { + const map = new Map(); + 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 ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+ ); + } + + return ( +
+ {/* Summary cards */} +
+ + + +
+ + {/* Filters */} +
+
+ + Filter: +
+ + {cases && cases.length > 0 && ( + + )} +
+ + {/* Deadline list */} + {filtered.length === 0 ? ( +
+ +

Keine Fristen gefunden

+
+ ) : ( +
+ {filtered.map((deadline) => { + const urgency = getUrgency(deadline); + const config = urgencyConfig[urgency]; + const caseInfo = caseMap.get(deadline.case_id); + + return ( +
+
+
+
+ + {deadline.title} + + + {config.label} + + {deadline.status === "completed" && ( + + Erledigt + + )} +
+
+ + {format(parseISO(deadline.due_date), "dd. MMM yyyy", { locale: de })} + + {caseInfo && ( + <> + · + + {caseInfo.case_number} — {caseInfo.title} + + + )} + {deadline.source !== "manual" && ( + <> + · + {deadline.source} + + )} +
+
+ {deadline.status !== "completed" && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index d3b9e8e..b6c4bf6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -69,6 +69,13 @@ class ApiClient { }); } + patch(path: string, body?: unknown) { + return this.request(path, { + method: "PATCH", + body: body ? JSON.stringify(body) : undefined, + }); + } + delete(path: string) { return this.request(path, { method: "DELETE" }); } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b3535b6..2b08e38 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -104,49 +104,52 @@ export interface Document { updated_at: string; } -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 { +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; - case_number: string; - case_title: string; - status: string; + original_due_date: string; + was_adjusted: boolean; } -export interface UpcomingAppointment { - id: string; - title: string; - start_at: string; - case_number: string | null; - location: string | null; -} - -export interface RecentActivityItem { - event_type: string | null; - title: string; - case_number: string; - event_date: string | null; -} - -export interface DashboardData { - deadline_summary: DeadlineSummary; - case_summary: CaseSummary; - upcoming_deadlines: UpcomingDeadline[]; - upcoming_appointments: UpcomingAppointment[]; - recent_activity: RecentActivityItem[]; +export interface CalculateResponse { + proceeding_type: string; + trigger_event_date: string; + deadlines: CalculatedDeadline[]; } export interface ApiError {