Compare commits
16 Commits
mai/linus/
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325fbeb5de | ||
|
|
19bea8d058 | ||
|
|
661135d137 | ||
|
|
f8d97546e9 | ||
|
|
45605c803b | ||
|
|
e57b7c48ed | ||
|
|
c5c3f41e08 | ||
|
|
d0197a091c | ||
|
|
fe97fed56d | ||
|
|
b49992b9c0 | ||
|
|
8bb8d7fed8 | ||
|
|
b4f3b26cbe | ||
|
|
6e9345fcfe | ||
|
|
785df2ced4 | ||
|
|
749273fba7 | ||
|
|
0ab2e8b383 |
@@ -3,11 +3,16 @@
|
||||
|
||||
# Backend
|
||||
PORT=8080
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||
|
||||
# Supabase (required for database access)
|
||||
SUPABASE_URL=
|
||||
# Supabase (required for database + auth)
|
||||
SUPABASE_URL=https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_KEY=
|
||||
SUPABASE_JWT_SECRET=
|
||||
|
||||
# Claude API (required for AI features)
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# CalDAV (configured per-tenant in tenant settings, not env vars)
|
||||
# See tenant.settings.caldav JSON field
|
||||
|
||||
2
Makefile
2
Makefile
@@ -37,7 +37,7 @@ test-backend:
|
||||
cd backend && go test ./...
|
||||
|
||||
test-frontend:
|
||||
@echo "No frontend tests configured yet"
|
||||
cd frontend && bun run test
|
||||
|
||||
# Clean
|
||||
clean:
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/logging"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logging.Setup()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
slog.Error("failed to load config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
database, err := db.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
authMW := auth.NewMiddleware(cfg.SupabaseJWTSecret, database)
|
||||
handler := router.New(database, authMW, cfg)
|
||||
|
||||
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
|
||||
// Start CalDAV sync service
|
||||
calDAVSvc := services.NewCalDAVService(database)
|
||||
calDAVSvc.Start()
|
||||
defer calDAVSvc.Stop()
|
||||
|
||||
handler := router.New(database, authMW, cfg, calDAVSvc)
|
||||
|
||||
slog.Info("starting KanzlAI API server", "port", cfg.Port)
|
||||
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
||||
log.Fatal(err)
|
||||
slog.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,17 @@ module mgit.msbls.de/m/KanzlAI-mGMT
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.27.1 // indirect
|
||||
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
|
||||
|
||||
@@ -1,6 +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=
|
||||
@@ -11,7 +23,14 @@ 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=
|
||||
@@ -24,3 +43,7 @@ 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=
|
||||
|
||||
74
backend/internal/handlers/ai_handler_test.go
Normal file
74
backend/internal/handlers/ai_handler_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAIExtractDeadlines_EmptyInput(t *testing.T) {
|
||||
h := &AIHandler{}
|
||||
|
||||
body := `{"text":""}`
|
||||
r := httptest.NewRequest("POST", "/api/ai/extract-deadlines", bytes.NewBufferString(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExtractDeadlines(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "provide either a PDF file or text" {
|
||||
t.Errorf("unexpected error: %s", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIExtractDeadlines_InvalidJSON(t *testing.T) {
|
||||
h := &AIHandler{}
|
||||
|
||||
r := httptest.NewRequest("POST", "/api/ai/extract-deadlines", bytes.NewBufferString(`{broken`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ExtractDeadlines(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISummarizeCase_MissingCaseID(t *testing.T) {
|
||||
h := &AIHandler{}
|
||||
|
||||
body := `{"case_id":""}`
|
||||
r := httptest.NewRequest("POST", "/api/ai/summarize-case", bytes.NewBufferString(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SummarizeCase(w, r)
|
||||
|
||||
// Without auth context, the resolveTenant will fail first
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAISummarizeCase_InvalidJSON(t *testing.T) {
|
||||
h := &AIHandler{}
|
||||
|
||||
r := httptest.NewRequest("POST", "/api/ai/summarize-case", bytes.NewBufferString(`not-json`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.SummarizeCase(w, r)
|
||||
|
||||
// Without auth context, the resolveTenant will fail first
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
196
backend/internal/handlers/appointment_handler_test.go
Normal file
196
backend/internal/handlers/appointment_handler_test.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
)
|
||||
|
||||
func TestAppointmentCreate_NoTenant(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentCreate_MissingTitle(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
body := `{"start_at":"2026-04-01T10:00:00Z"}`
|
||||
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(body))
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "title is required" {
|
||||
t.Errorf("unexpected error: %s", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentCreate_MissingStartAt(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
body := `{"title":"Test Appointment"}`
|
||||
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(body))
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "start_at is required" {
|
||||
t.Errorf("unexpected error: %s", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentCreate_InvalidJSON(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/appointments", bytes.NewBufferString(`{broken`))
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentList_NoTenant(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/appointments", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.List(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentUpdate_NoTenant(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("PUT", "/api/appointments/"+uuid.New().String(), bytes.NewBufferString(`{}`))
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Update(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentUpdate_InvalidID(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("PUT", "/api/appointments/not-uuid", bytes.NewBufferString(`{}`))
|
||||
r.SetPathValue("id", "not-uuid")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Update(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentDelete_NoTenant(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/appointments/"+uuid.New().String(), nil)
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentDelete_InvalidID(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/appointments/bad", nil)
|
||||
r.SetPathValue("id", "bad")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentList_InvalidCaseID(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/appointments?case_id=bad", nil)
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.List(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentList_InvalidStartFrom(t *testing.T) {
|
||||
h := &AppointmentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/appointments?start_from=not-a-date", nil)
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.List(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
83
backend/internal/handlers/calculate_handler_test.go
Normal file
83
backend/internal/handlers/calculate_handler_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCalculate_MissingFields(t *testing.T) {
|
||||
h := &CalculateHandlers{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty body",
|
||||
body: `{}`,
|
||||
want: "proceeding_type and trigger_event_date are required",
|
||||
},
|
||||
{
|
||||
name: "missing trigger_event_date",
|
||||
body: `{"proceeding_type":"INF"}`,
|
||||
want: "proceeding_type and trigger_event_date are required",
|
||||
},
|
||||
{
|
||||
name: "missing proceeding_type",
|
||||
body: `{"trigger_event_date":"2026-06-01"}`,
|
||||
want: "proceeding_type and trigger_event_date are required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(tt.body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Calculate(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != tt.want {
|
||||
t.Errorf("expected error %q, got %q", tt.want, resp["error"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculate_InvalidDateFormat(t *testing.T) {
|
||||
h := &CalculateHandlers{}
|
||||
body := `{"proceeding_type":"INF","trigger_event_date":"01-06-2026"}`
|
||||
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Calculate(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "invalid trigger_event_date format, expected YYYY-MM-DD" {
|
||||
t.Errorf("unexpected error: %s", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculate_InvalidJSON(t *testing.T) {
|
||||
h := &CalculateHandlers{}
|
||||
r := httptest.NewRequest("POST", "/api/deadlines/calculate", bytes.NewBufferString(`not-json`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Calculate(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
68
backend/internal/handlers/caldav.go
Normal file
68
backend/internal/handlers/caldav.go
Normal 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)
|
||||
}
|
||||
177
backend/internal/handlers/case_handler_test.go
Normal file
177
backend/internal/handlers/case_handler_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
)
|
||||
|
||||
func TestCaseCreate_NoAuth(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(`{}`))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseCreate_MissingFields(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
body := `{"case_number":"","title":""}`
|
||||
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(body))
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["error"] != "case_number and title are required" {
|
||||
t.Errorf("unexpected error: %s", resp["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseCreate_InvalidJSON(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/cases", bytes.NewBufferString(`not-json`))
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Create(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseGet_InvalidID(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/cases/not-a-uuid", nil)
|
||||
r.SetPathValue("id", "not-a-uuid")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Get(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseGet_NoTenant(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/cases/"+uuid.New().String(), nil)
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Get(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseList_NoTenant(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/cases", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.List(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseUpdate_InvalidID(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
body := `{"title":"Updated"}`
|
||||
r := httptest.NewRequest("PUT", "/api/cases/bad-id", bytes.NewBufferString(body))
|
||||
r.SetPathValue("id", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Update(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseUpdate_InvalidJSON(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
caseID := uuid.New().String()
|
||||
r := httptest.NewRequest("PUT", "/api/cases/"+caseID, bytes.NewBufferString(`{bad`))
|
||||
r.SetPathValue("id", caseID)
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Update(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseDelete_NoTenant(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/cases/"+uuid.New().String(), nil)
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaseDelete_InvalidID(t *testing.T) {
|
||||
h := &CaseHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/cases/bad-id", nil)
|
||||
r.SetPathValue("id", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
19
backend/internal/handlers/dashboard_handler_test.go
Normal file
19
backend/internal/handlers/dashboard_handler_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDashboardGet_NoTenant(t *testing.T) {
|
||||
h := &DashboardHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/dashboard", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Get(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
166
backend/internal/handlers/document_handler_test.go
Normal file
166
backend/internal/handlers/document_handler_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||
)
|
||||
|
||||
func TestDocumentListByCase_NoTenant(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/cases/"+uuid.New().String()+"/documents", nil)
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListByCase(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentListByCase_InvalidCaseID(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/cases/bad-id/documents", nil)
|
||||
r.SetPathValue("id", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.ListByCase(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentUpload_NoTenant(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/cases/"+uuid.New().String()+"/documents", nil)
|
||||
r.SetPathValue("id", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Upload(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentUpload_InvalidCaseID(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("POST", "/api/cases/bad-id/documents", nil)
|
||||
r.SetPathValue("id", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Upload(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDownload_NoTenant(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/documents/"+uuid.New().String(), nil)
|
||||
r.SetPathValue("docId", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Download(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDownload_InvalidID(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/documents/bad-id", nil)
|
||||
r.SetPathValue("docId", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Download(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentGetMeta_NoTenant(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/documents/"+uuid.New().String()+"/meta", nil)
|
||||
r.SetPathValue("docId", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.GetMeta(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentGetMeta_InvalidID(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("GET", "/api/documents/bad-id/meta", nil)
|
||||
r.SetPathValue("docId", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.GetMeta(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDelete_NoTenant(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/documents/"+uuid.New().String(), nil)
|
||||
r.SetPathValue("docId", uuid.New().String())
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDelete_InvalidID(t *testing.T) {
|
||||
h := &DocumentHandler{}
|
||||
r := httptest.NewRequest("DELETE", "/api/documents/bad-id", nil)
|
||||
r.SetPathValue("docId", "bad-id")
|
||||
ctx := auth.ContextWithTenantID(
|
||||
auth.ContextWithUserID(r.Context(), uuid.New()),
|
||||
uuid.New(),
|
||||
)
|
||||
r = r.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Delete(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
1148
backend/internal/integration_test.go
Normal file
1148
backend/internal/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
14
backend/internal/logging/logging.go
Normal file
14
backend/internal/logging/logging.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Setup initializes the global slog logger with JSON output for production.
|
||||
func Setup() {
|
||||
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})
|
||||
slog.SetDefault(slog.New(handler))
|
||||
}
|
||||
98
backend/internal/middleware/ratelimit.go
Normal file
98
backend/internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TokenBucket implements a simple per-IP token bucket rate limiter.
|
||||
type TokenBucket struct {
|
||||
mu sync.Mutex
|
||||
buckets map[string]*bucket
|
||||
rate float64 // tokens per second
|
||||
burst int // max tokens
|
||||
}
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
lastTime time.Time
|
||||
}
|
||||
|
||||
// NewTokenBucket creates a rate limiter allowing rate requests per second with burst capacity.
|
||||
func NewTokenBucket(rate float64, burst int) *TokenBucket {
|
||||
tb := &TokenBucket{
|
||||
buckets: make(map[string]*bucket),
|
||||
rate: rate,
|
||||
burst: burst,
|
||||
}
|
||||
// Periodically clean up stale buckets
|
||||
go tb.cleanup()
|
||||
return tb
|
||||
}
|
||||
|
||||
func (tb *TokenBucket) allow(key string) bool {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
b, ok := tb.buckets[key]
|
||||
if !ok {
|
||||
b = &bucket{tokens: float64(tb.burst), lastTime: time.Now()}
|
||||
tb.buckets[key] = b
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(b.lastTime).Seconds()
|
||||
b.tokens += elapsed * tb.rate
|
||||
if b.tokens > float64(tb.burst) {
|
||||
b.tokens = float64(tb.burst)
|
||||
}
|
||||
b.lastTime = now
|
||||
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
func (tb *TokenBucket) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
tb.mu.Lock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
for key, b := range tb.buckets {
|
||||
if b.lastTime.Before(cutoff) {
|
||||
delete(tb.buckets, key)
|
||||
}
|
||||
}
|
||||
tb.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Limit wraps an http.Handler with rate limiting.
|
||||
func (tb *TokenBucket) Limit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if !tb.allow(ip) {
|
||||
slog.Warn("rate limit exceeded", "ip", ip, "path", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "10")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":"rate limit exceeded, try again later"}`))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LimitFunc wraps an http.HandlerFunc with rate limiting.
|
||||
func (tb *TokenBucket) LimitFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
limited := tb.Limit(http.HandlerFunc(next))
|
||||
return limited.ServeHTTP
|
||||
}
|
||||
70
backend/internal/middleware/ratelimit_test.go
Normal file
70
backend/internal/middleware/ratelimit_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTokenBucket_AllowsBurst(t *testing.T) {
|
||||
tb := NewTokenBucket(1.0, 5) // 1/sec, burst 5
|
||||
|
||||
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Should allow burst of 5 requests
|
||||
for i := 0; i < 5; i++ {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 6th request should be rate limited
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("request 6: expected 429, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBucket_DifferentIPs(t *testing.T) {
|
||||
tb := NewTokenBucket(1.0, 2) // 1/sec, burst 2
|
||||
|
||||
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Exhaust IP1's bucket
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ip1 request %d: expected 200, got %d", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// IP1 should now be limited
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("ip1 request 3: expected 429, got %d", w.Code)
|
||||
}
|
||||
|
||||
// IP2 should still work
|
||||
req = httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("X-Forwarded-For", "5.6.7.8")
|
||||
w = httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("ip2 request 1: expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,20 @@ package router
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"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/middleware"
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||
)
|
||||
|
||||
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) http.Handler {
|
||||
func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *services.CalDAVService) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Services
|
||||
@@ -60,6 +63,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) 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)
|
||||
@@ -112,10 +116,18 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) http.Handler
|
||||
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
|
||||
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
|
||||
|
||||
// AI endpoints
|
||||
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
|
||||
if aiH != nil {
|
||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiH.ExtractDeadlines)
|
||||
scoped.HandleFunc("POST /api/ai/summarize-case", aiH.SummarizeCase)
|
||||
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
|
||||
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(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
|
||||
@@ -123,7 +135,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config) http.Handler
|
||||
|
||||
mux.Handle("/api/", authMW.RequireAuth(api))
|
||||
|
||||
return mux
|
||||
return requestLogger(mux)
|
||||
}
|
||||
|
||||
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
||||
@@ -138,3 +150,34 @@ func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func requestLogger(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip health checks to reduce noise
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(sw, r)
|
||||
|
||||
slog.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", sw.status,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
687
backend/internal/services/caldav_service.go
Normal file
687
backend/internal/services/caldav_service.go
Normal file
@@ -0,0 +1,687 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"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()
|
||||
})
|
||||
slog.Info("CalDAV sync service started")
|
||||
}
|
||||
|
||||
// Stop gracefully stops the background sync.
|
||||
func (s *CalDAVService) Stop() {
|
||||
close(s.stopCh)
|
||||
s.wg.Wait()
|
||||
slog.Info("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 {
|
||||
slog.Error("CalDAV: failed to load tenant configs", "error", 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 {
|
||||
slog.Error("CalDAV: sync failed", "tenant_id", tid, "error", 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 {
|
||||
slog.Error("CalDAV: failed to log conflict event", "error", 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"
|
||||
}
|
||||
124
backend/internal/services/caldav_service_test.go
Normal file
124
backend/internal/services/caldav_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
167
backend/seed/demo_data.sql
Normal file
167
backend/seed/demo_data.sql
Normal file
@@ -0,0 +1,167 @@
|
||||
-- KanzlAI Demo Data
|
||||
-- Creates 1 test tenant, 5 cases with deadlines and appointments
|
||||
-- Run with: psql $DATABASE_URL -f demo_data.sql
|
||||
|
||||
SET search_path TO kanzlai, public;
|
||||
|
||||
-- Demo tenant
|
||||
INSERT INTO tenants (id, name, slug, settings) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'Kanzlei Siebels & Partner', 'siebels-partner', '{}')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Link both users to the demo tenant
|
||||
INSERT INTO user_tenants (user_id, tenant_id, role) VALUES
|
||||
('1da9374d-a8a6-49fc-a2ec-5ddfa91d522d', 'a0000000-0000-0000-0000-000000000001', 'owner'),
|
||||
('ac6c9501-3757-4a6d-8b97-2cff4288382b', 'a0000000-0000-0000-0000-000000000001', 'member')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- Case 1: Patentverletzung (patent infringement) — active
|
||||
-- ============================================================
|
||||
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'2026/001', 'TechCorp GmbH ./. InnovatAG — Patentverletzung EP 1234567',
|
||||
'patent', 'UPC München (Lokalkammer)', 'UPC_CFI-123/2026',
|
||||
'active');
|
||||
|
||||
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'TechCorp GmbH', 'claimant', 'RA Dr. Siebels'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'InnovatAG', 'defendant', 'RA Müller');
|
||||
|
||||
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'Klageerwiderung einreichen', CURRENT_DATE + INTERVAL '3 days', CURRENT_DATE + INTERVAL '1 day', 'pending', 'manual'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'Beweisangebote nachreichen', CURRENT_DATE + INTERVAL '14 days', CURRENT_DATE + INTERVAL '10 days', 'pending', 'manual'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'Schriftsatz Anspruch 3', CURRENT_DATE - INTERVAL '2 days', CURRENT_DATE - INTERVAL '5 days', 'pending', 'manual');
|
||||
|
||||
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'Mündliche Verhandlung', CURRENT_DATE + INTERVAL '21 days' + TIME '10:00', CURRENT_DATE + INTERVAL '21 days' + TIME '12:00',
|
||||
'UPC München, Saal 4', 'hearing');
|
||||
|
||||
-- ============================================================
|
||||
-- Case 2: Markenrecht (trademark) — active
|
||||
-- ============================================================
|
||||
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||
('c0000000-0000-0000-0000-000000000002',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'2026/002', 'BrandHouse ./. CopyShop UG — Markenverletzung DE 30201234',
|
||||
'trademark', 'LG Hamburg', '315 O 78/26',
|
||||
'active');
|
||||
|
||||
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'BrandHouse SE', 'claimant', 'RA Dr. Siebels'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'CopyShop UG', 'defendant', 'RA Weber');
|
||||
|
||||
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'Antrag einstweilige Verfügung', CURRENT_DATE + INTERVAL '5 days', CURRENT_DATE + INTERVAL '2 days', 'pending', 'manual'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'Abmahnung Fristablauf', CURRENT_DATE + INTERVAL '30 days', CURRENT_DATE + INTERVAL '25 days', 'pending', 'manual');
|
||||
|
||||
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'Mandantenbesprechung BrandHouse', CURRENT_DATE + INTERVAL '2 days' + TIME '14:00', CURRENT_DATE + INTERVAL '2 days' + TIME '15:30',
|
||||
'Kanzlei, Besprechungsraum 1', 'consultation');
|
||||
|
||||
-- ============================================================
|
||||
-- Case 3: Arbeitsgericht (labor law) — active
|
||||
-- ============================================================
|
||||
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||
('c0000000-0000-0000-0000-000000000003',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'2026/003', 'Schmidt ./. AutoWerk Bayern GmbH — Kündigungsschutz',
|
||||
'labor', 'ArbG München', '12 Ca 456/26',
|
||||
'active');
|
||||
|
||||
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'Klaus Schmidt', 'claimant', 'RA Dr. Siebels'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'AutoWerk Bayern GmbH', 'defendant', 'RA Fischer');
|
||||
|
||||
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'Kündigungsschutzklage einreichen (3-Wochen-Frist)', CURRENT_DATE + INTERVAL '7 days', CURRENT_DATE + INTERVAL '4 days', 'pending', 'manual'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'Stellungnahme Arbeitgeber', CURRENT_DATE + INTERVAL '28 days', CURRENT_DATE + INTERVAL '21 days', 'pending', 'manual');
|
||||
|
||||
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'Güteverhandlung', CURRENT_DATE + INTERVAL '35 days' + TIME '09:00', CURRENT_DATE + INTERVAL '35 days' + TIME '10:00',
|
||||
'ArbG München, Saal 12', 'hearing');
|
||||
|
||||
-- ============================================================
|
||||
-- Case 4: Mietrecht (tenancy) — active
|
||||
-- ============================================================
|
||||
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||
('c0000000-0000-0000-0000-000000000004',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'2026/004', 'Hausverwaltung Zentral ./. Meier — Mietrückstand',
|
||||
'civil', 'AG München', '432 C 1234/26',
|
||||
'active');
|
||||
|
||||
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'Hausverwaltung Zentral GmbH', 'claimant', 'RA Dr. Siebels'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'Thomas Meier', 'defendant', NULL);
|
||||
|
||||
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'Mahnbescheid beantragen', CURRENT_DATE + INTERVAL '10 days', CURRENT_DATE + INTERVAL '7 days', 'pending', 'manual'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'Räumungsfrist prüfen', CURRENT_DATE + INTERVAL '60 days', CURRENT_DATE + INTERVAL '50 days', 'pending', 'manual');
|
||||
|
||||
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'Besprechung Hausverwaltung', CURRENT_DATE + INTERVAL '4 days' + TIME '11:00', CURRENT_DATE + INTERVAL '4 days' + TIME '12:00',
|
||||
'Kanzlei, Besprechungsraum 2', 'meeting');
|
||||
|
||||
-- ============================================================
|
||||
-- Case 5: Erbrecht (inheritance) — closed
|
||||
-- ============================================================
|
||||
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||
('c0000000-0000-0000-0000-000000000005',
|
||||
'a0000000-0000-0000-0000-000000000001',
|
||||
'2025/042', 'Nachlass Wagner — Erbauseinandersetzung',
|
||||
'civil', 'AG Starnberg', '3 VI 891/25',
|
||||
'closed');
|
||||
|
||||
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||
'Maria Wagner', 'claimant', 'RA Dr. Siebels'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||
'Peter Wagner', 'defendant', 'RA Braun');
|
||||
|
||||
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source, completed_at) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||
'Erbscheinsantrag einreichen', CURRENT_DATE - INTERVAL '30 days', CURRENT_DATE - INTERVAL '37 days', 'completed', 'manual', CURRENT_DATE - INTERVAL '32 days');
|
||||
|
||||
-- ============================================================
|
||||
-- Case events for realistic activity feed
|
||||
-- ============================================================
|
||||
INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, created_at, updated_at) VALUES
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'case_created', 'Akte angelegt', 'Patentverletzungsklage TechCorp ./. InnovatAG eröffnet', NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'party_added', 'Partei hinzugefügt', 'TechCorp GmbH als Kläger eingetragen', NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||
'case_created', 'Akte angelegt', 'Markenrechtsstreit BrandHouse ./. CopyShop eröffnet', NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||
'case_created', 'Akte angelegt', 'Kündigungsschutzklage Schmidt eröffnet', NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||
'case_created', 'Akte angelegt', 'Mietrückstand Hausverwaltung ./. Meier eröffnet', NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||
'status_changed', 'Fristablauf überschritten', 'Schriftsatz Anspruch 3 ist überfällig', NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||
'case_created', 'Akte angelegt', 'Erbauseinandersetzung Wagner eröffnet', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||
'status_changed', 'Akte geschlossen', 'Erbscheinsverfahren abgeschlossen', NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days');
|
||||
@@ -6,6 +6,12 @@ services:
|
||||
- "8080"
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
@@ -16,6 +22,9 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||
expose:
|
||||
- "3000"
|
||||
depends_on:
|
||||
@@ -23,6 +32,8 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://backend:8080
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
|
||||
@@ -10,6 +10,10 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
RUN mkdir -p public
|
||||
RUN bun run build
|
||||
|
||||
|
||||
@@ -19,25 +19,97 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.14",
|
||||
"jsdom": "24.1.3",
|
||||
"msw": "^2.12.14",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "2.1.8",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
@@ -114,6 +186,16 @@
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
|
||||
|
||||
"@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
|
||||
|
||||
"@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="],
|
||||
|
||||
"@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="],
|
||||
|
||||
"@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -124,6 +206,8 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
|
||||
|
||||
"@next/env": ["@next/env@15.5.14", "", {}, "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA=="],
|
||||
@@ -154,6 +238,62 @@
|
||||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="],
|
||||
|
||||
"@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="],
|
||||
|
||||
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
|
||||
@@ -210,8 +350,18 @@
|
||||
|
||||
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
|
||||
|
||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||
|
||||
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
|
||||
|
||||
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
|
||||
|
||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -224,6 +374,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -284,12 +436,30 @@
|
||||
|
||||
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@2.1.8", "", { "dependencies": { "@vitest/spy": "2.1.8", "@vitest/utils": "2.1.8", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@2.1.8", "", { "dependencies": { "@vitest/spy": "2.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@2.1.8", "", { "dependencies": { "@vitest/utils": "2.1.8", "pathe": "^1.1.2" } }, "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@2.1.8", "", { "dependencies": { "@vitest/pretty-format": "2.1.8", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@2.1.8", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@2.1.8", "", { "dependencies": { "@vitest/pretty-format": "2.1.8", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
@@ -312,10 +482,14 @@
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"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=="],
|
||||
@@ -330,6 +504,8 @@
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
@@ -340,24 +516,40 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
|
||||
|
||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
|
||||
|
||||
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
|
||||
|
||||
"cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||
|
||||
"data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
@@ -368,22 +560,34 @@
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
@@ -392,6 +596,8 @@
|
||||
|
||||
"es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
@@ -400,6 +606,10 @@
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
|
||||
@@ -432,8 +642,12 @@
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
@@ -460,6 +674,10 @@
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="],
|
||||
@@ -468,6 +686,8 @@
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
@@ -486,6 +706,8 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
@@ -500,14 +722,26 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
@@ -532,6 +766,8 @@
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -540,10 +776,14 @@
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
@@ -574,6 +814,8 @@
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsdom": ["jsdom@24.1.3", "", { "dependencies": { "cssstyle": "^4.0.1", "data-urls": "^5.0.0", "decimal.js": "^10.4.3", "form-data": "^4.0.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.5", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.12", "parse5": "^7.1.2", "rrweb-cssom": "^0.7.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^4.1.4", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^2.11.2" }, "optionalPeers": ["canvas"] }, "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
@@ -622,8 +864,14 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||
|
||||
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -632,12 +880,22 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msw": ["msw@2.12.14", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ=="],
|
||||
|
||||
"mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="],
|
||||
@@ -648,6 +906,8 @@
|
||||
|
||||
"node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="],
|
||||
|
||||
"nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -666,6 +926,8 @@
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
@@ -674,12 +936,20 @@
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
@@ -690,10 +960,16 @@
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
@@ -704,18 +980,30 @@
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
|
||||
|
||||
"rrweb-cssom": ["rrweb-cssom@0.7.1", "", {}, "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
@@ -724,6 +1012,10 @@
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -748,14 +1040,28 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="],
|
||||
@@ -768,8 +1074,12 @@
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
@@ -778,14 +1088,36 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
|
||||
|
||||
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
|
||||
|
||||
"tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
|
||||
|
||||
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
|
||||
@@ -794,6 +1126,8 @@
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
@@ -808,10 +1142,32 @@
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
|
||||
|
||||
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
|
||||
|
||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"url-parse": ["url-parse@1.5.10", "", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vite-node": ["vite-node@2.1.8", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg=="],
|
||||
|
||||
"vitest": ["vitest@2.1.8", "", { "dependencies": { "@vitest/expect": "2.1.8", "@vitest/mocker": "2.1.8", "@vitest/pretty-format": "^2.1.8", "@vitest/runner": "2.1.8", "@vitest/snapshot": "2.1.8", "@vitest/spy": "2.1.8", "@vitest/utils": "2.1.8", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.8", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.8", "@vitest/ui": "2.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
@@ -822,12 +1178,28 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="],
|
||||
|
||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
@@ -842,6 +1214,10 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
|
||||
@@ -850,6 +1226,14 @@
|
||||
|
||||
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.8", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ=="],
|
||||
|
||||
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@2.1.8", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ=="],
|
||||
|
||||
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
@@ -864,10 +1248,18 @@
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"msw/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
|
||||
|
||||
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
@@ -5,7 +5,7 @@ const nextConfig: NextConfig = {
|
||||
rewrites: async () => [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${process.env.API_URL || "http://localhost:8080"}/:path*`,
|
||||
destination: `${process.env.API_URL || "http://localhost:8080"}/api/:path*`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.9.0",
|
||||
@@ -21,14 +23,20 @@
|
||||
"sonner": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.14",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"jsdom": "24.1.3",
|
||||
"msw": "^2.12.14",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
47
frontend/src/__tests__/CaseOverviewGrid.test.tsx
Normal file
47
frontend/src/__tests__/CaseOverviewGrid.test.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid";
|
||||
import type { CaseSummary } from "@/lib/types";
|
||||
|
||||
describe("CaseOverviewGrid", () => {
|
||||
const defaultData: CaseSummary = {
|
||||
active_count: 15,
|
||||
new_this_month: 4,
|
||||
closed_count: 8,
|
||||
};
|
||||
|
||||
it("renders all three case categories", () => {
|
||||
render(<CaseOverviewGrid data={defaultData} />);
|
||||
|
||||
expect(screen.getByText("Aktive Akten")).toBeInTheDocument();
|
||||
expect(screen.getByText("Neu (Monat)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Abgeschlossen")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct counts", () => {
|
||||
render(<CaseOverviewGrid data={defaultData} />);
|
||||
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("8")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the section header", () => {
|
||||
render(<CaseOverviewGrid data={defaultData} />);
|
||||
|
||||
expect(screen.getByText("Aktenübersicht")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles zero counts", () => {
|
||||
const zeroData: CaseSummary = {
|
||||
active_count: 0,
|
||||
new_this_month: 0,
|
||||
closed_count: 0,
|
||||
};
|
||||
|
||||
render(<CaseOverviewGrid data={zeroData} />);
|
||||
|
||||
const zeros = screen.getAllByText("0");
|
||||
expect(zeros).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
67
frontend/src/__tests__/DeadlineTrafficLights.test.tsx
Normal file
67
frontend/src/__tests__/DeadlineTrafficLights.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DeadlineTrafficLights } from "@/components/dashboard/DeadlineTrafficLights";
|
||||
import type { DeadlineSummary } from "@/lib/types";
|
||||
|
||||
describe("DeadlineTrafficLights", () => {
|
||||
const defaultData: DeadlineSummary = {
|
||||
overdue_count: 3,
|
||||
due_this_week: 5,
|
||||
due_next_week: 2,
|
||||
ok_count: 10,
|
||||
};
|
||||
|
||||
it("renders all three traffic light cards", () => {
|
||||
render(<DeadlineTrafficLights data={defaultData} />);
|
||||
|
||||
expect(screen.getByText("Überfällig")).toBeInTheDocument();
|
||||
expect(screen.getByText("Diese Woche")).toBeInTheDocument();
|
||||
expect(screen.getByText("Im Zeitplan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct counts", () => {
|
||||
render(<DeadlineTrafficLights data={defaultData} />);
|
||||
|
||||
// Overdue: 3
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
// This week: 5
|
||||
expect(screen.getByText("5")).toBeInTheDocument();
|
||||
// OK: ok_count + due_next_week = 10 + 2 = 12
|
||||
expect(screen.getByText("12")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays zero counts correctly", () => {
|
||||
const zeroData: DeadlineSummary = {
|
||||
overdue_count: 0,
|
||||
due_this_week: 0,
|
||||
due_next_week: 0,
|
||||
ok_count: 0,
|
||||
};
|
||||
|
||||
render(<DeadlineTrafficLights data={zeroData} />);
|
||||
|
||||
const zeros = screen.getAllByText("0");
|
||||
expect(zeros).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("calls onFilter with correct key when clicked", () => {
|
||||
const onFilter = vi.fn();
|
||||
render(<DeadlineTrafficLights data={defaultData} onFilter={onFilter} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Überfällig"));
|
||||
expect(onFilter).toHaveBeenCalledWith("overdue");
|
||||
|
||||
fireEvent.click(screen.getByText("Diese Woche"));
|
||||
expect(onFilter).toHaveBeenCalledWith("this_week");
|
||||
|
||||
fireEvent.click(screen.getByText("Im Zeitplan"));
|
||||
expect(onFilter).toHaveBeenCalledWith("ok");
|
||||
});
|
||||
|
||||
it("renders without onFilter prop (no crash)", () => {
|
||||
expect(() => {
|
||||
render(<DeadlineTrafficLights data={defaultData} />);
|
||||
fireEvent.click(screen.getByText("Überfällig"));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
143
frontend/src/__tests__/LoginPage.test.tsx
Normal file
143
frontend/src/__tests__/LoginPage.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
||||
}));
|
||||
|
||||
// Mock Supabase
|
||||
const mockSignInWithPassword = vi.fn();
|
||||
const mockSignInWithOtp = vi.fn();
|
||||
vi.mock("@/lib/supabase/client", () => ({
|
||||
createClient: () => ({
|
||||
auth: {
|
||||
signInWithPassword: mockSignInWithPassword,
|
||||
signInWithOtp: mockSignInWithOtp,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
const { default: LoginPage } = await import(
|
||||
"@/app/(auth)/login/page"
|
||||
);
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders login form with email and password fields", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("KanzlAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("Melden Sie sich an")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("E-Mail")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Passwort")).toBeInTheDocument();
|
||||
expect(screen.getByText("Anmelden")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders mode toggle between Passwort and Magic Link", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
// "Passwort" appears twice (toggle button + label), so use getAllByText
|
||||
const passwortElements = screen.getAllByText("Passwort");
|
||||
expect(passwortElements.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Magic Link")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to magic link mode and hides password field", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.click(screen.getByText("Magic Link"));
|
||||
|
||||
expect(screen.queryByLabelText("Passwort")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Link senden")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits password login to Supabase", async () => {
|
||||
mockSignInWithPassword.mockResolvedValue({ error: null });
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("E-Mail"), {
|
||||
target: { value: "test@kanzlei.de" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Passwort"), {
|
||||
target: { value: "geheim123" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Anmelden"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSignInWithPassword).toHaveBeenCalledWith({
|
||||
email: "test@kanzlei.de",
|
||||
password: "geheim123",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to / on successful login", async () => {
|
||||
mockSignInWithPassword.mockResolvedValue({ error: null });
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("E-Mail"), {
|
||||
target: { value: "test@kanzlei.de" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Passwort"), {
|
||||
target: { value: "geheim123" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Anmelden"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/");
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays error on failed login", async () => {
|
||||
mockSignInWithPassword.mockResolvedValue({
|
||||
error: { message: "Ungültige Anmeldedaten" },
|
||||
});
|
||||
render(<LoginPage />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("E-Mail"), {
|
||||
target: { value: "bad@email.de" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Passwort"), {
|
||||
target: { value: "wrong" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Anmelden"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Ungültige Anmeldedaten")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows magic link sent confirmation", async () => {
|
||||
mockSignInWithOtp.mockResolvedValue({ error: null });
|
||||
render(<LoginPage />);
|
||||
|
||||
// Switch to magic link mode
|
||||
fireEvent.click(screen.getByText("Magic Link"));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("E-Mail"), {
|
||||
target: { value: "test@kanzlei.de" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Link senden"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Link gesendet")).toBeInTheDocument();
|
||||
expect(screen.getByText("Zurueck zum Login")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("has link to registration page", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
const registerLink = screen.getByText("Registrieren");
|
||||
expect(registerLink).toBeInTheDocument();
|
||||
expect(registerLink.closest("a")).toHaveAttribute("href", "/register");
|
||||
});
|
||||
});
|
||||
182
frontend/src/__tests__/api.test.ts
Normal file
182
frontend/src/__tests__/api.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock Supabase client
|
||||
const mockGetSession = vi.fn();
|
||||
vi.mock("@/lib/supabase/client", () => ({
|
||||
createClient: () => ({
|
||||
auth: {
|
||||
getSession: mockGetSession,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Must import after mock setup
|
||||
const { api } = await import("@/lib/api");
|
||||
|
||||
describe("ApiClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
mockGetSession.mockResolvedValue({
|
||||
data: { session: { access_token: "test-token-123" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("constructs correct URL with /api base", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ cases: [], total: 0 }), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/cases");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/cases",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not double-prefix /api/", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/deadlines");
|
||||
|
||||
const url = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(url).toBe("/api/deadlines");
|
||||
expect(url).not.toContain("/api/api/");
|
||||
});
|
||||
|
||||
it("sets Authorization header from Supabase session", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/cases");
|
||||
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBe("Bearer test-token-123");
|
||||
});
|
||||
|
||||
it("sets X-Tenant-ID header from localStorage", async () => {
|
||||
localStorage.setItem("kanzlai_tenant_id", "tenant-uuid-123");
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/cases");
|
||||
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers["X-Tenant-ID"]).toBe("tenant-uuid-123");
|
||||
});
|
||||
|
||||
it("omits X-Tenant-ID when not in localStorage", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/cases");
|
||||
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers["X-Tenant-ID"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits Authorization when no session", async () => {
|
||||
mockGetSession.mockResolvedValue({
|
||||
data: { session: null },
|
||||
});
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.get("/cases");
|
||||
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers["Authorization"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends POST with JSON body", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "new-id" }), { status: 201 }),
|
||||
);
|
||||
|
||||
const body = { case_number: "TEST/001", title: "Test Case" };
|
||||
await api.post("/cases", body);
|
||||
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(requestInit.method).toBe("POST");
|
||||
expect(requestInit.body).toBe(JSON.stringify(body));
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("sends PUT with JSON body", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.put("/cases/123", { title: "Updated" });
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(requestInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("sends PATCH with JSON body", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.patch("/deadlines/123/complete", {});
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(requestInit.method).toBe("PATCH");
|
||||
});
|
||||
|
||||
it("sends DELETE", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({}), { status: 200 }),
|
||||
);
|
||||
|
||||
await api.delete("/cases/123");
|
||||
const requestInit = fetchSpy.mock.calls[0][1] as RequestInit;
|
||||
expect(requestInit.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("throws ApiError on non-ok response", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "not found" }), { status: 404 }),
|
||||
);
|
||||
|
||||
await expect(api.get("/cases/nonexistent")).rejects.toEqual({
|
||||
error: "not found",
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles 204 No Content response", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(null, { status: 204 }),
|
||||
);
|
||||
|
||||
const result = await api.delete("/appointments/123");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles error response without JSON body", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("Internal Server Error", {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(api.get("/broken")).rejects.toEqual({
|
||||
error: "Internal Server Error",
|
||||
status: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
7
frontend/src/__tests__/setup.ts
Normal file
7
frontend/src/__tests__/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export default function AIExtractPage() {
|
||||
|
||||
const { data: casesData } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<PaginatedResponse<Case>>("/api/cases"),
|
||||
queryFn: () => api.get<PaginatedResponse<Case>>("/cases"),
|
||||
});
|
||||
|
||||
const cases = casesData?.data ?? [];
|
||||
@@ -40,12 +40,12 @@ export default function AIExtractPage() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
response = await api.postFormData<ExtractionResponse>(
|
||||
"/api/ai/extract-deadlines",
|
||||
"/ai/extract-deadlines",
|
||||
formData,
|
||||
);
|
||||
} else {
|
||||
response = await api.post<ExtractionResponse>(
|
||||
"/api/ai/extract-deadlines",
|
||||
"/ai/extract-deadlines",
|
||||
{ text },
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export default function AIExtractPage() {
|
||||
|
||||
try {
|
||||
const promises = deadlines.map((d) =>
|
||||
api.post(`/api/cases/${selectedCaseId}/deadlines`, {
|
||||
api.post(`/cases/${selectedCaseId}/deadlines`, {
|
||||
title: d.title,
|
||||
due_date: d.due_date ?? "",
|
||||
source: "ai_extraction",
|
||||
|
||||
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal file
116
frontend/src/app/(app)/einstellungen/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal file
40
frontend/src/app/(app)/einstellungen/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export default function FristenPage() {
|
||||
|
||||
const { data: deadlines } = useQuery({
|
||||
queryKey: ["deadlines"],
|
||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
||||
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
99
frontend/src/app/(app)/termine/page.tsx
Normal file
99
frontend/src/app/(app)/termine/page.tsx
Normal 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[]>("/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>
|
||||
);
|
||||
}
|
||||
160
frontend/src/components/appointments/AppointmentCalendar.tsx
Normal file
160
frontend/src/components/appointments/AppointmentCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
265
frontend/src/components/appointments/AppointmentList.tsx
Normal file
265
frontend/src/components/appointments/AppointmentList.tsx
Normal 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[]>("/appointments"),
|
||||
});
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/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>
|
||||
);
|
||||
}
|
||||
280
frontend/src/components/appointments/AppointmentModal.tsx
Normal file
280
frontend/src/components/appointments/AppointmentModal.tsx
Normal 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 }>("/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>("/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(`/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>
|
||||
);
|
||||
}
|
||||
@@ -39,14 +39,14 @@ export function DeadlineCalculator() {
|
||||
|
||||
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||
queryKey: ["proceeding-types"],
|
||||
queryFn: () => api.get<ProceedingType[]>("/api/proceeding-types"),
|
||||
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||
});
|
||||
|
||||
const calculateMutation = useMutation({
|
||||
mutationFn: (params: {
|
||||
proceeding_type: string;
|
||||
trigger_event_date: string;
|
||||
}) => api.post<CalculateResponse>("/api/deadlines/calculate", params),
|
||||
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||
});
|
||||
|
||||
function handleCalculate(e: React.FormEvent) {
|
||||
|
||||
@@ -54,12 +54,12 @@ export function DeadlineList() {
|
||||
|
||||
const { data: deadlines, isLoading } = useQuery({
|
||||
queryKey: ["deadlines"],
|
||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
||||
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||
});
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<Case[]>("/api/cases"),
|
||||
queryFn: () => api.get<Case[]>("/cases"),
|
||||
});
|
||||
|
||||
const completeMutation = useMutation({
|
||||
|
||||
144
frontend/src/components/documents/DocumentList.tsx
Normal file
144
frontend/src/components/documents/DocumentList.tsx
Normal 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`;
|
||||
}
|
||||
144
frontend/src/components/documents/DocumentUpload.tsx
Normal file
144
frontend/src/components/documents/DocumentUpload.tsx
Normal 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`;
|
||||
}
|
||||
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal file
329
frontend/src/components/settings/CalDAVSettings.tsx
Normal 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>("/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>("/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>
|
||||
);
|
||||
}
|
||||
167
frontend/src/components/settings/TeamSettings.tsx
Normal file
167
frontend/src/components/settings/TeamSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -152,6 +152,30 @@ export interface CalculateResponse {
|
||||
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;
|
||||
|
||||
@@ -55,6 +55,6 @@ export async function middleware(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
"/((?!api/|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
26
frontend/vitest.config.ts
Normal file
26
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/__tests__/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
globals: true,
|
||||
css: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
// Disable PostCSS processing — Tailwind v4's plugin isn't compatible with Vite's PostCSS loader
|
||||
postcss: {
|
||||
plugins: [],
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
jsx: "automatic",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user