Compare commits

...

6 Commits

Author SHA1 Message Date
m
dea49f6f8e feat: group proceeding types by jurisdiction in UI dropdowns
- DeadlineCalculator: use optgroup to group by UPC/DE
- DeadlineWizard: add section headers for each jurisdiction
- CaseForm: replace hardcoded TYPE_OPTIONS with API-fetched
  proceeding types grouped by jurisdiction
- Added 3 new DE proceeding types to DB: DE_PATENT,
  DE_NULLITY, DE_OPPOSITION
2026-03-30 14:29:42 +02:00
m
5e401d2eac fix: default deadline calculator date to today 2026-03-30 14:21:08 +02:00
m
3f90904e0c fix: update search_path from kanzlai to mgmt after migration 2026-03-30 14:18:35 +02:00
m
f285d4451d refactor: switch to youpc.org Supabase, remove separate YouPCDatabaseURL 2026-03-30 14:09:52 +02:00
m
bf1b1cdd82 refactor: remove YouPCDatabaseURL, use same DB connection for case finder
Now that KanzlAI is on the youpc.org Supabase instance, the separate
YouPCDatabaseURL connection is unnecessary. The main database connection
can query mlex.* tables directly since they're on the same Postgres.

- Remove YouPCDatabaseURL from config
- Remove separate sqlx.Connect block in main.go
- Pass main database handle as youpcDB parameter to router
- Update CLAUDE.md: mgmt schema in youpc.org (was kanzlai in flexsiebels)
2026-03-30 14:01:19 +02:00
m
9d89b97ad5 fix: open reports endpoints to all roles, only billing restricted 2026-03-30 13:44:04 +02:00
8 changed files with 104 additions and 68 deletions

View File

@@ -18,7 +18,7 @@ frontend/ Next.js 15 (TypeScript, Tailwind CSS, App Router)
- **Frontend:** Next.js 15 with TypeScript, Tailwind CSS v4, App Router, Bun
- **Backend:** Go (standard library HTTP server)
- **Database:** Supabase (PostgreSQL) — `kanzlai` schema in flexsiebels instance
- **Database:** Supabase (PostgreSQL) — `mgmt` schema in youpc.org instance
- **Deploy:** Dokploy on mLake, domain: kanzlai.msbls.de
## Development

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"os"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
@@ -34,21 +33,6 @@ func main() {
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
// Optional: connect to youpc.org database for similar case finder
var youpcDB *sqlx.DB
if cfg.YouPCDatabaseURL != "" {
youpcDB, err = sqlx.Connect("postgres", cfg.YouPCDatabaseURL)
if err != nil {
slog.Warn("failed to connect to youpc.org database — similar case finder disabled", "error", err)
youpcDB = nil
} else {
youpcDB.SetMaxOpenConns(5)
youpcDB.SetMaxIdleConns(2)
defer youpcDB.Close()
slog.Info("connected to youpc.org database for similar case finder")
}
}
// Start CalDAV sync service
calDAVSvc := services.NewCalDAVService(database)
calDAVSvc.Start()
@@ -59,7 +43,7 @@ func main() {
notifSvc.Start()
defer notifSvc.Stop()
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, youpcDB)
handler := router.New(database, authMW, cfg, calDAVSvc, notifSvc, database)
slog.Info("starting KanzlAI API server", "port", cfg.Port)
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {

View File

@@ -14,7 +14,6 @@ type Config struct {
SupabaseJWTSecret string
AnthropicAPIKey string
FrontendOrigin string
YouPCDatabaseURL string // read-only connection to youpc.org Supabase for similar case finder
}
func Load() (*Config, error) {
@@ -27,7 +26,6 @@ func Load() (*Config, error) {
SupabaseJWTSecret: os.Getenv("SUPABASE_JWT_SECRET"),
AnthropicAPIKey: os.Getenv("ANTHROPIC_API_KEY"),
FrontendOrigin: getEnv("FRONTEND_ORIGIN", "https://kanzlai.msbls.de"),
YouPCDatabaseURL: os.Getenv("YOUPC_DATABASE_URL"),
}
if cfg.DatabaseURL == "" {

View File

@@ -13,8 +13,8 @@ func Connect(databaseURL string) (*sqlx.DB, error) {
return nil, fmt.Errorf("connecting to database: %w", err)
}
// Set search_path so queries use kanzlai schema by default
if _, err := db.Exec("SET search_path TO kanzlai, public"); err != nil {
// Set search_path so queries use mgmt schema by default
if _, err := db.Exec("SET search_path TO mgmt, public"); err != nil {
db.Close()
return nil, fmt.Errorf("setting search_path: %w", err)
}

View File

@@ -215,10 +215,10 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus)
}
// Reports — billing permission (partners + owners)
scoped.HandleFunc("GET /api/reports/cases", perm(auth.PermManageBilling, reportH.Cases))
scoped.HandleFunc("GET /api/reports/deadlines", perm(auth.PermManageBilling, reportH.Deadlines))
scoped.HandleFunc("GET /api/reports/workload", perm(auth.PermManageBilling, reportH.Workload))
// Reports — cases/deadlines/workload open to all, billing restricted
scoped.HandleFunc("GET /api/reports/cases", reportH.Cases)
scoped.HandleFunc("GET /api/reports/deadlines", reportH.Deadlines)
scoped.HandleFunc("GET /api/reports/workload", reportH.Workload)
scoped.HandleFunc("GET /api/reports/billing", perm(auth.PermManageBilling, reportH.Billing))
// Time entries — all can view/create, tied to cases

View File

@@ -1,16 +1,14 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api";
import type { ProceedingType } from "@/lib/types";
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" },
];
const JURISDICTION_LABELS: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
export interface CaseFormData {
case_number: string;
@@ -34,6 +32,10 @@ export function CaseForm({
isSubmitting,
submitLabel = "Akte anlegen",
}: CaseFormProps) {
const { data: proceedingTypes } = useQuery({
queryKey: ["proceeding-types"],
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
});
const [form, setForm] = useState<CaseFormData>({
case_number: initialData?.case_number ?? "",
title: initialData?.title ?? "",
@@ -139,11 +141,24 @@ export function CaseForm({
onChange={(e) => update("case_type", e.target.value)}
className={inputClass}
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
<option value="">-- Typ wählen --</option>
{(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<optgroup key={jurisdiction} label={JURISDICTION_LABELS[jurisdiction] ?? jurisdiction}>
{types.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>

View File

@@ -35,7 +35,9 @@ const inputClass =
export function DeadlineCalculator() {
const [proceedingType, setProceedingType] = useState("");
const [triggerDate, setTriggerDate] = useState("");
const [triggerDate, setTriggerDate] = useState(
new Date().toISOString().split("T")[0],
);
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
queryKey: ["proceeding-types"],
@@ -83,11 +85,27 @@ export function DeadlineCalculator() {
className={inputClass}
>
<option value="">Bitte wählen...</option>
{proceedingTypes?.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
</option>
))}
{(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
const labels: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<optgroup key={jurisdiction} label={labels[jurisdiction] ?? jurisdiction}>
{types.map((pt) => (
<option key={pt.id} value={pt.code}>
{pt.name} ({pt.code})
</option>
))}
</optgroup>
));
})()}
</select>
</div>
<div>

View File

@@ -276,32 +276,53 @@ export function DeadlineWizard() {
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="mt-4 space-y-4">
{typesLoading ? (
<div className="col-span-full flex justify-center py-4">
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
</div>
) : (
proceedingTypes?.map((pt) => (
<button
key={pt.id}
onClick={() => handleTypeSelect(pt.code)}
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
selectedType === pt.code
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50"
}`}
>
<div className="flex items-center gap-1.5">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: pt.default_color }}
/>
<span className="text-xs font-semibold">{pt.code}</span>
(() => {
const grouped = new Map<string, ProceedingType[]>();
for (const pt of proceedingTypes ?? []) {
const key = pt.jurisdiction ?? "Sonstige";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(pt);
}
const labels: Record<string, string> = {
UPC: "UPC-Verfahren",
DE: "Deutsche Patentverfahren",
};
return Array.from(grouped.entries()).map(([jurisdiction, types]) => (
<div key={jurisdiction}>
<div className="mb-2 text-xs font-medium text-neutral-500">
{labels[jurisdiction] ?? jurisdiction}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{types.map((pt) => (
<button
key={pt.id}
onClick={() => handleTypeSelect(pt.code)}
className={`rounded-lg border px-3 py-2.5 text-left transition-all ${
selectedType === pt.code
? "border-neutral-900 bg-neutral-900 text-white shadow-sm"
: "border-neutral-200 bg-white text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50"
}`}
>
<div className="flex items-center gap-1.5">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: pt.default_color }}
/>
<span className="text-xs font-semibold">{pt.code}</span>
</div>
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
</button>
))}
</div>
</div>
<div className="mt-1 text-xs leading-tight opacity-80">{pt.name}</div>
</button>
))
));
})()
)}
</div>
</div>
@@ -444,7 +465,7 @@ export function DeadlineWizard() {
<Scale className="h-6 w-6 text-neutral-400" />
</div>
<p className="mt-3 text-sm font-medium text-neutral-700">
UPC-Fristenbestimmung
Fristenbestimmung
</p>
<p className="mt-1 max-w-sm text-xs text-neutral-500">
Waehlen Sie die Verfahrensart und geben Sie das Datum des ausloesenden Ereignisses ein.