Compare commits
14 Commits
mai/ritchi
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0712d9a367 | ||
|
|
cd31e76d07 | ||
|
|
f42b7ddec7 | ||
|
|
50bfa3deb4 | ||
|
|
e635efa71e | ||
|
|
12e0407025 | ||
|
|
325fbeb5de | ||
|
|
19bea8d058 | ||
|
|
661135d137 | ||
|
|
f8d97546e9 | ||
|
|
45605c803b | ||
|
|
e57b7c48ed | ||
|
|
d0197a091c | ||
|
|
fe97fed56d |
665
DESIGN-dashboard-redesign.md
Normal file
665
DESIGN-dashboard-redesign.md
Normal file
@@ -0,0 +1,665 @@
|
||||
# Design: Dashboard Redesign + Detail Pages
|
||||
|
||||
**Task:** t-kz-060
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-03-25
|
||||
**Status:** Design proposal
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current dashboard is a read-only status board. Cards show counts but don't link anywhere. Timeline items are inert. Quick actions navigate to list pages rather than creation flows. There are no detail pages for individual events, deadlines, or appointments. Notes don't exist as a first-class entity. Case detail tabs use local state instead of URL segments, breaking deep linking and back navigation.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Everything clickable goes somewhere** — no dead-end UI
|
||||
2. **Breadcrumb navigation** — always know where you are, one click to go back
|
||||
3. **German labels throughout** — consistent with existing convention
|
||||
4. **Mobile responsive** — sidebar collapses, cards stack, touch targets >= 44px
|
||||
5. **Information density over whitespace** — law firm users want data, not decoration
|
||||
6. **URL-driven state** — tabs, filters, and views reflected in the URL for deep linking
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Dashboard Redesign
|
||||
|
||||
### 1.1 Traffic Light Cards → Click-to-Filter
|
||||
|
||||
**Current:** Three cards (Ueberfaellig / Diese Woche / Im Zeitplan) show counts. `onFilter` prop exists but is never wired up in `dashboard/page.tsx`.
|
||||
|
||||
**Proposed:**
|
||||
|
||||
Clicking a traffic light card navigates to `/fristen?status={filter}`:
|
||||
|
||||
| Card | Navigation Target |
|
||||
|------|------------------|
|
||||
| Ueberfaellig (red) | `/fristen?status=overdue` |
|
||||
| Diese Woche (amber) | `/fristen?status=this_week` |
|
||||
| Im Zeitplan (green) | `/fristen?status=ok` |
|
||||
|
||||
**Implementation:**
|
||||
- Replace `onFilter` callback with `next/link` navigation using `href`
|
||||
- `DeadlineTrafficLights` becomes a pure link-based component (no callback needed)
|
||||
- `/fristen` page reads `searchParams.status` and pre-applies the filter
|
||||
- The DeadlineList component already supports status filtering — just needs to read from URL
|
||||
|
||||
**Changes:**
|
||||
- `DeadlineTrafficLights.tsx`: Replace `<button onClick>` with `<Link href="/fristen?status=...">`
|
||||
- `fristen/page.tsx`: Read `searchParams` and pass as initial filter to DeadlineList
|
||||
- `DeadlineList.tsx`: Accept `initialStatus` prop from URL params
|
||||
|
||||
### 1.2 Case Overview Grid → Click-to-Filter
|
||||
|
||||
**Current:** Three static metrics (Aktive Akten / Neu / Abgeschlossen). No links.
|
||||
|
||||
**Proposed:**
|
||||
|
||||
| Card | Navigation Target |
|
||||
|------|------------------|
|
||||
| Aktive Akten | `/cases?status=active` |
|
||||
| Neu (Monat) | `/cases?status=active&since=month` |
|
||||
| Abgeschlossen | `/cases?status=closed` |
|
||||
|
||||
**Implementation:**
|
||||
- Wrap each metric row in `<Link>` to `/cases` with appropriate query params
|
||||
- Cases list page already has filtering — needs to read URL params on mount
|
||||
- Add visual hover state (arrow icon on hover, background highlight)
|
||||
|
||||
**Changes:**
|
||||
- `CaseOverviewGrid.tsx`: Each row becomes a `<Link>` with hover arrow
|
||||
- `cases/page.tsx`: Read `searchParams` for initial filter state
|
||||
|
||||
### 1.3 Timeline Items → Click-to-Navigate
|
||||
|
||||
**Current:** Timeline entries show deadline/appointment info but are not clickable. No link to the parent case or the item itself.
|
||||
|
||||
**Proposed:**
|
||||
|
||||
Each timeline entry becomes a clickable row:
|
||||
|
||||
- **Deadline entries**: Click navigates to `/fristen/{id}` (new deadline detail page)
|
||||
- **Appointment entries**: Click navigates to `/termine/{id}` (new appointment detail page)
|
||||
- **Case reference** (Az. / case_number): Secondary click target linking to `/cases/{case_id}`
|
||||
|
||||
**Visual changes:**
|
||||
- Add `cursor-pointer` and hover state (`hover:bg-neutral-100` transition)
|
||||
- Add a small chevron-right icon on the right edge
|
||||
- Case number becomes a subtle underlined link (click stops propagation)
|
||||
|
||||
**Data changes:**
|
||||
- `UpcomingDeadline` needs `case_id` field (currently missing from the dashboard query — the backend model has it but the SQL join doesn't select it)
|
||||
- `UpcomingAppointment` already has `case_id`
|
||||
|
||||
**Backend change:**
|
||||
- `dashboard_service.go` line 112: Add `d.case_id` to the upcoming deadlines SELECT
|
||||
- `DashboardService.UpcomingDeadline` struct: Add `CaseID uuid.UUID` field
|
||||
- Frontend `UpcomingDeadline` type: Already has `case_id` (it's in types.ts but the backend doesn't send it)
|
||||
|
||||
### 1.4 Quick Actions → Proper Navigation
|
||||
|
||||
**Current:** "Frist eintragen" goes to `/fristen` (list page), not a creation flow. "CalDAV Sync" goes to `/einstellungen`.
|
||||
|
||||
**Proposed:**
|
||||
|
||||
| Action | Current Target | New Target |
|
||||
|--------|---------------|------------|
|
||||
| Neue Akte | `/cases/new` | `/cases/new` (keep) |
|
||||
| Frist eintragen | `/fristen` | `/fristen/neu` (new creation page) |
|
||||
| Neuer Termin | (missing) | `/termine/neu` (new creation page) |
|
||||
| AI Analyse | `/ai/extract` | `/ai/extract` (keep) |
|
||||
|
||||
Replace "CalDAV Sync" with "Neuer Termin" — CalDAV sync is a settings function, not a daily quick action. Creating an appointment is something a secretary does multiple times per day.
|
||||
|
||||
**Changes:**
|
||||
- `QuickActions.tsx`: Update hrefs, swap CalDAV for appointment creation
|
||||
- Create `/fristen/neu/page.tsx` — standalone deadline creation form (select case, fill fields)
|
||||
- Create `/termine/neu/page.tsx` — standalone appointment creation form
|
||||
|
||||
### 1.5 AI Summary Card → Refresh Button
|
||||
|
||||
**Current:** Rule-based summary text, no refresh mechanism. Card regenerates on page load but not on demand.
|
||||
|
||||
**Proposed:**
|
||||
- Add a small refresh icon button (RefreshCw) in the card header, next to "KI-Zusammenfassung"
|
||||
- Clicking it calls `refetch()` on the dashboard query (passed as prop)
|
||||
- Show a brief spinning animation during refetch
|
||||
- If/when real AI summarization is wired up, this button triggers `POST /api/ai/summarize-dashboard` (future endpoint)
|
||||
|
||||
**Changes:**
|
||||
- `AISummaryCard.tsx`: Accept `onRefresh` prop, add button with spinning state
|
||||
- `dashboard/page.tsx`: Pass `refetch` to AISummaryCard
|
||||
|
||||
### 1.6 Dashboard Layout: Add Recent Activity Section
|
||||
|
||||
**Current:** The backend returns `recent_activity` (last 10 case events) but the frontend ignores it entirely.
|
||||
|
||||
**Proposed:**
|
||||
- Add a "Letzte Aktivitaet" section below the timeline, full width
|
||||
- Shows the 10 most recent case events in a compact list
|
||||
- Each row: event icon (by type) | title | case number (linked) | relative time
|
||||
- Clicking a row navigates to the case event detail page `/cases/{case_id}/ereignisse/{event_id}`
|
||||
|
||||
**Changes:**
|
||||
- New component: `RecentActivityList.tsx` in `components/dashboard/`
|
||||
- `dashboard/page.tsx`: Add section below the main grid
|
||||
- Add `RecentActivity` type to `types.ts` (needs `case_id` and `event_id` fields from backend)
|
||||
- Backend: Add `case_id` and `id` to the recent activity query
|
||||
|
||||
---
|
||||
|
||||
## Part 2: New Pages
|
||||
|
||||
### 2.1 Deadline Detail Page — `/fristen/{id}`
|
||||
|
||||
**Route:** `src/app/(app)/fristen/[id]/page.tsx`
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
Breadcrumb: Dashboard > Fristen > {deadline.title}
|
||||
+---------------------------------------------------------+
|
||||
| [Status Badge] {deadline.title} [Erledigen] |
|
||||
| Fällig: 28. März 2026 |
|
||||
+---------------------------------------------------------+
|
||||
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
|
||||
| Quelle: Berechnet (R.118 RoP) |
|
||||
| Ursprüngliches Datum: 25. März 2026 |
|
||||
| Warnungsdatum: 21. März 2026 |
|
||||
+---------------------------------------------------------+
|
||||
| Notizen [Bearbeiten]|
|
||||
| Fristverlängerung beantragt am 20.03. |
|
||||
+---------------------------------------------------------+
|
||||
| Verlauf |
|
||||
| ○ Erstellt am 15.03.2026 |
|
||||
| ○ Warnung gesendet am 21.03.2026 |
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Data requirements:**
|
||||
- `GET /api/deadlines/{id}` — new endpoint returning full deadline with case info
|
||||
- Returns: Deadline + associated case (number, title, id) + notes
|
||||
|
||||
**Sections:**
|
||||
1. **Header**: Status badge (Offen/Erledigt/Ueberfaellig), title, "Erledigen" action button
|
||||
2. **Due date**: Large, with relative time ("in 3 Tagen" / "vor 2 Tagen ueberfaellig")
|
||||
3. **Context panel**: Parent case (linked), source (manual/calculated/caldav), rule reference, original vs adjusted date
|
||||
4. **Notes section**: Free-text notes (existing `notes` field on deadline), inline edit
|
||||
5. **Activity log**: Timeline of changes to this deadline (future: from case_events filtered by deadline)
|
||||
|
||||
**Backend additions:**
|
||||
- `GET /api/deadlines/{id}` — new handler returning single deadline with case join
|
||||
- Handler: `deadlines.go` add `Get` method
|
||||
- Service: `deadline_service.go` add `GetByID` with case join
|
||||
|
||||
### 2.2 Appointment Detail Page — `/termine/{id}`
|
||||
|
||||
**Route:** `src/app/(app)/termine/[id]/page.tsx`
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
Breadcrumb: Dashboard > Termine > {appointment.title}
|
||||
+---------------------------------------------------------+
|
||||
| {appointment.title} [Bearbeiten] [X] |
|
||||
| Typ: Verhandlung |
|
||||
+---------------------------------------------------------+
|
||||
| Datum: 28. März 2026, 10:00 – 12:00 Uhr |
|
||||
| Ort: UPC München, Saal 3 |
|
||||
+---------------------------------------------------------+
|
||||
| Akte: Az. 2024/001 — Müller v. Schmidt [→ Zur Akte] |
|
||||
+---------------------------------------------------------+
|
||||
| Beschreibung |
|
||||
| Erste mündliche Verhandlung... |
|
||||
+---------------------------------------------------------+
|
||||
| Notizen [+ Neu] |
|
||||
| ○ 25.03. — Mandant über Termin informiert |
|
||||
| ○ 24.03. — Schriftsatz vorbereitet |
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Data requirements:**
|
||||
- `GET /api/appointments/{id}` — new endpoint returning single appointment with case info
|
||||
- Notes: Uses new `notes` table (see Part 3)
|
||||
|
||||
**Backend additions:**
|
||||
- `GET /api/appointments/{id}` — new handler
|
||||
- Handler: `appointments.go` add `Get` method
|
||||
- Service: `appointment_service.go` add `GetByID` with optional case join
|
||||
|
||||
### 2.3 Case Event Detail Page — `/cases/{id}/ereignisse/{eventId}`
|
||||
|
||||
**Route:** `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx`
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
Breadcrumb: Akten > Az. 2024/001 > Verlauf > {event.title}
|
||||
+---------------------------------------------------------+
|
||||
| [Event Type Icon] {event.title} |
|
||||
| 25. März 2026, 14:30 |
|
||||
+---------------------------------------------------------+
|
||||
| Beschreibung |
|
||||
| Statusänderung: aktiv → geschlossen |
|
||||
+---------------------------------------------------------+
|
||||
| Metadaten |
|
||||
| Erstellt von: max.mustermann@kanzlei.de |
|
||||
| Typ: status_changed |
|
||||
+---------------------------------------------------------+
|
||||
| Notizen [+ Neu] |
|
||||
| (keine Notizen) |
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Data requirements:**
|
||||
- `GET /api/case-events/{id}` — new endpoint
|
||||
- Notes: Uses new `notes` table
|
||||
|
||||
**Backend additions:**
|
||||
- New handler: `case_events.go` with `Get` method
|
||||
- New service method: `CaseEventService.GetByID`
|
||||
- Or extend existing case handler to include event fetching
|
||||
|
||||
### 2.4 Standalone Deadline Creation — `/fristen/neu`
|
||||
|
||||
**Route:** `src/app/(app)/fristen/neu/page.tsx`
|
||||
|
||||
**Layout:**
|
||||
|
||||
```
|
||||
Breadcrumb: Fristen > Neue Frist
|
||||
+---------------------------------------------------------+
|
||||
| Neue Frist anlegen |
|
||||
+---------------------------------------------------------+
|
||||
| Akte*: [Dropdown: Aktenauswahl] |
|
||||
| Bezeichnung*: [________________________] |
|
||||
| Beschreibung: [________________________] |
|
||||
| Fällig am*: [Datumsauswahl] |
|
||||
| Warnung am: [Datumsauswahl] |
|
||||
| Notizen: [Textarea] |
|
||||
+---------------------------------------------------------+
|
||||
| [Abbrechen] [Frist anlegen]|
|
||||
+---------------------------------------------------------+
|
||||
```
|
||||
|
||||
Reuses existing deadline creation logic but as a standalone page rather than requiring the user to first navigate to a case. Case is selected via dropdown.
|
||||
|
||||
### 2.5 Standalone Appointment Creation — `/termine/neu`
|
||||
|
||||
**Route:** `src/app/(app)/termine/neu/page.tsx`
|
||||
|
||||
Same pattern as deadline creation. Reuses AppointmentModal fields but as a full page form. Appointment can optionally be linked to a case.
|
||||
|
||||
### 2.6 Case Detail Tabs → URL Segments
|
||||
|
||||
**Current:** Tabs use `useState<TabKey>` — no URL change, no deep linking, no browser back.
|
||||
|
||||
**Proposed route structure:**
|
||||
|
||||
```
|
||||
/cases/{id} → redirects to /cases/{id}/verlauf
|
||||
/cases/{id}/verlauf → Timeline tab
|
||||
/cases/{id}/fristen → Deadlines tab
|
||||
/cases/{id}/dokumente → Documents tab
|
||||
/cases/{id}/parteien → Parties tab
|
||||
/cases/{id}/notizen → Notes tab (new)
|
||||
```
|
||||
|
||||
**Implementation approach:**
|
||||
|
||||
Use Next.js nested layouts with a shared layout for the case header + tab bar:
|
||||
|
||||
```
|
||||
src/app/(app)/cases/[id]/
|
||||
layout.tsx # Case header + tab navigation
|
||||
page.tsx # Redirect to ./verlauf
|
||||
verlauf/page.tsx # Timeline
|
||||
fristen/page.tsx # Deadlines
|
||||
dokumente/page.tsx # Documents
|
||||
parteien/page.tsx # Parties
|
||||
notizen/page.tsx # Notes (new)
|
||||
```
|
||||
|
||||
The `layout.tsx` fetches case data and renders the header + tab bar. Each child page renders its tab content. The active tab is determined by the current pathname.
|
||||
|
||||
**Benefits:**
|
||||
- Deep linking: `/cases/abc123/fristen` opens directly to the deadlines tab
|
||||
- Browser back button works between tabs
|
||||
- Each tab can have its own loading state
|
||||
- Bookmarkable
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Notes System
|
||||
|
||||
### 3.1 Data Model
|
||||
|
||||
Notes are a polymorphic entity — they can be attached to cases, deadlines, appointments, or case events.
|
||||
|
||||
**New table: `kanzlai.notes`**
|
||||
|
||||
```sql
|
||||
CREATE TABLE kanzlai.notes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES kanzlai.tenants(id),
|
||||
|
||||
-- Polymorphic parent reference (exactly one must be set)
|
||||
case_id UUID REFERENCES kanzlai.cases(id) ON DELETE CASCADE,
|
||||
deadline_id UUID REFERENCES kanzlai.deadlines(id) ON DELETE CASCADE,
|
||||
appointment_id UUID REFERENCES kanzlai.appointments(id) ON DELETE CASCADE,
|
||||
case_event_id UUID REFERENCES kanzlai.case_events(id) ON DELETE CASCADE,
|
||||
|
||||
content TEXT NOT NULL,
|
||||
created_by UUID, -- auth.users reference
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
-- Ensure exactly one parent is set
|
||||
CONSTRAINT notes_single_parent CHECK (
|
||||
(CASE WHEN case_id IS NOT NULL THEN 1 ELSE 0 END +
|
||||
CASE WHEN deadline_id IS NOT NULL THEN 1 ELSE 0 END +
|
||||
CASE WHEN appointment_id IS NOT NULL THEN 1 ELSE 0 END +
|
||||
CASE WHEN case_event_id IS NOT NULL THEN 1 ELSE 0 END) = 1
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes for efficient lookup by parent
|
||||
CREATE INDEX idx_notes_case ON kanzlai.notes(tenant_id, case_id) WHERE case_id IS NOT NULL;
|
||||
CREATE INDEX idx_notes_deadline ON kanzlai.notes(tenant_id, deadline_id) WHERE deadline_id IS NOT NULL;
|
||||
CREATE INDEX idx_notes_appointment ON kanzlai.notes(tenant_id, appointment_id) WHERE appointment_id IS NOT NULL;
|
||||
CREATE INDEX idx_notes_case_event ON kanzlai.notes(tenant_id, case_event_id) WHERE case_event_id IS NOT NULL;
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE kanzlai.notes ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY notes_tenant_isolation ON kanzlai.notes
|
||||
USING (tenant_id IN (
|
||||
SELECT tenant_id FROM kanzlai.user_tenants WHERE user_id = auth.uid()
|
||||
));
|
||||
```
|
||||
|
||||
### 3.2 Why Polymorphic Table vs Separate Tables
|
||||
|
||||
**Considered alternatives:**
|
||||
|
||||
1. **Separate notes per entity** (case_notes, deadline_notes, etc.) — More tables, duplicated logic, harder to search across all notes.
|
||||
2. **Generic `entity_type` + `entity_id` pattern** — Loses FK constraints, can't cascade delete, harder to query with joins.
|
||||
3. **Polymorphic with nullable FKs** (chosen) — FK constraints maintained, cascade deletes work, partial indexes keep queries fast, single service/handler. The CHECK constraint ensures data integrity.
|
||||
|
||||
### 3.3 Backend Model & API
|
||||
|
||||
**Go model:**
|
||||
|
||||
```go
|
||||
type Note struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"`
|
||||
CaseID *uuid.UUID `db:"case_id" json:"case_id,omitempty"`
|
||||
DeadlineID *uuid.UUID `db:"deadline_id" json:"deadline_id,omitempty"`
|
||||
AppointmentID *uuid.UUID `db:"appointment_id" json:"appointment_id,omitempty"`
|
||||
CaseEventID *uuid.UUID `db:"case_event_id" json:"case_event_id,omitempty"`
|
||||
Content string `db:"content" json:"content"`
|
||||
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
```
|
||||
|
||||
**API endpoints:**
|
||||
|
||||
```
|
||||
GET /api/notes?case_id={id} # List notes for a case
|
||||
GET /api/notes?deadline_id={id} # List notes for a deadline
|
||||
GET /api/notes?appointment_id={id} # List notes for an appointment
|
||||
GET /api/notes?case_event_id={id} # List notes for a case event
|
||||
POST /api/notes # Create note (body includes parent ID)
|
||||
PUT /api/notes/{id} # Update note content
|
||||
DELETE /api/notes/{id} # Delete note
|
||||
```
|
||||
|
||||
Single endpoint with query parameter filtering — simpler than nested routes, works uniformly across all parent types.
|
||||
|
||||
**Service methods:**
|
||||
|
||||
```go
|
||||
type NoteService struct { db *sqlx.DB }
|
||||
|
||||
func (s *NoteService) ListByParent(ctx, tenantID, parentType, parentID) ([]Note, error)
|
||||
func (s *NoteService) Create(ctx, tenantID, note) (*Note, error)
|
||||
func (s *NoteService) Update(ctx, tenantID, noteID, content) (*Note, error)
|
||||
func (s *NoteService) Delete(ctx, tenantID, noteID) error
|
||||
```
|
||||
|
||||
### 3.4 Notes UI Component
|
||||
|
||||
Reusable `<NotesList>` component used on every detail page:
|
||||
|
||||
```
|
||||
+------------------------------------------------------------+
|
||||
| Notizen [+ Neu] |
|
||||
+------------------------------------------------------------+
|
||||
| m@kanzlei.de · 25. Mär 2026, 14:30 [X][E] |
|
||||
| Fristverlängerung beim Gericht beantragt. |
|
||||
+------------------------------------------------------------+
|
||||
| m@kanzlei.de · 24. Mär 2026, 10:15 [X][E] |
|
||||
| Mandant telefonisch über Sachstand informiert. |
|
||||
+------------------------------------------------------------+
|
||||
```
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface NotesListProps {
|
||||
parentType: "case" | "deadline" | "appointment" | "case_event";
|
||||
parentId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Fetches notes via `GET /api/notes?{parentType}_id={parentId}`
|
||||
- "Neu" button opens inline textarea (not a modal — faster for quick notes)
|
||||
- Each note shows: author, timestamp, content, edit/delete buttons
|
||||
- Edit is inline (textarea replaces content)
|
||||
- Optimistic updates via react-query mutation + invalidation
|
||||
- Empty state: "Keine Notizen vorhanden. Klicken Sie +, um eine Notiz hinzuzufügen."
|
||||
|
||||
### 3.5 Migration from `deadlines.notes` Field
|
||||
|
||||
The existing `deadlines.notes` text field should be migrated:
|
||||
1. For each deadline with a non-null `notes` value, create a corresponding row in the `notes` table with `deadline_id` set
|
||||
2. Drop the `deadlines.notes` column after migration
|
||||
3. This can be a one-time SQL migration script
|
||||
|
||||
---
|
||||
|
||||
## Part 4: Breadcrumb Navigation
|
||||
|
||||
### 4.1 Breadcrumb Component
|
||||
|
||||
New shared component: `src/components/layout/Breadcrumb.tsx`
|
||||
|
||||
```typescript
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string; // omit for current page (last item)
|
||||
}
|
||||
|
||||
function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
|
||||
// Renders: Home > Parent > Current
|
||||
// Each item with href is a Link, last item is plain text
|
||||
}
|
||||
```
|
||||
|
||||
**Placement:** At the top of every page, inside the main content area (not in the layout — different pages have different breadcrumbs).
|
||||
|
||||
### 4.2 Breadcrumb Patterns
|
||||
|
||||
| Page | Breadcrumb |
|
||||
|------|-----------|
|
||||
| Dashboard | Dashboard |
|
||||
| Fristen | Dashboard > Fristen |
|
||||
| Fristen Detail | Dashboard > Fristen > {title} |
|
||||
| Fristen Neu | Dashboard > Fristen > Neue Frist |
|
||||
| Termine | Dashboard > Termine |
|
||||
| Termine Detail | Dashboard > Termine > {title} |
|
||||
| Termine Neu | Dashboard > Termine > Neuer Termin |
|
||||
| Akten | Dashboard > Akten |
|
||||
| Akte Detail | Dashboard > Akten > {case_number} |
|
||||
| Akte > Fristen | Dashboard > Akten > {case_number} > Fristen |
|
||||
| Akte > Notizen | Dashboard > Akten > {case_number} > Notizen |
|
||||
| Ereignis Detail | Dashboard > Akten > {case_number} > Verlauf > {title} |
|
||||
| Einstellungen | Dashboard > Einstellungen |
|
||||
| AI Analyse | Dashboard > AI Analyse |
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Summary of Backend Changes
|
||||
|
||||
### New Endpoints
|
||||
|
||||
| Method | Path | Handler | Purpose |
|
||||
|--------|------|---------|---------|
|
||||
| GET | `/api/deadlines/{id}` | `deadlineH.Get` | Single deadline with case context |
|
||||
| GET | `/api/appointments/{id}` | `apptH.Get` | Single appointment with case context |
|
||||
| GET | `/api/case-events/{id}` | `eventH.Get` | Single case event |
|
||||
| GET | `/api/notes` | `noteH.List` | List notes (filtered by parent) |
|
||||
| POST | `/api/notes` | `noteH.Create` | Create note |
|
||||
| PUT | `/api/notes/{id}` | `noteH.Update` | Update note |
|
||||
| DELETE | `/api/notes/{id}` | `noteH.Delete` | Delete note |
|
||||
|
||||
### Modified Endpoints
|
||||
|
||||
| Endpoint | Change |
|
||||
|----------|--------|
|
||||
| `GET /api/dashboard` | Add `case_id`, `id` to recent_activity; add `case_id` to upcoming_deadlines query |
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/internal/models/note.go` | Note model |
|
||||
| `backend/internal/services/note_service.go` | Note CRUD service |
|
||||
| `backend/internal/handlers/notes.go` | Note HTTP handlers |
|
||||
| `backend/internal/handlers/case_events.go` | Case event detail handler |
|
||||
|
||||
### Database Migration
|
||||
|
||||
1. Create `kanzlai.notes` table with polymorphic FK pattern
|
||||
2. Migrate existing `deadlines.notes` data
|
||||
3. Drop `deadlines.notes` column
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Summary of Frontend Changes
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/app/(app)/fristen/[id]/page.tsx` | Deadline detail page |
|
||||
| `src/app/(app)/fristen/neu/page.tsx` | Standalone deadline creation |
|
||||
| `src/app/(app)/termine/[id]/page.tsx` | Appointment detail page |
|
||||
| `src/app/(app)/termine/neu/page.tsx` | Standalone appointment creation |
|
||||
| `src/app/(app)/cases/[id]/layout.tsx` | Case detail shared layout (header + tabs) |
|
||||
| `src/app/(app)/cases/[id]/verlauf/page.tsx` | Case timeline tab |
|
||||
| `src/app/(app)/cases/[id]/fristen/page.tsx` | Case deadlines tab |
|
||||
| `src/app/(app)/cases/[id]/dokumente/page.tsx` | Case documents tab |
|
||||
| `src/app/(app)/cases/[id]/parteien/page.tsx` | Case parties tab |
|
||||
| `src/app/(app)/cases/[id]/notizen/page.tsx` | Case notes tab (new) |
|
||||
| `src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx` | Case event detail |
|
||||
| `src/components/layout/Breadcrumb.tsx` | Reusable breadcrumb |
|
||||
| `src/components/notes/NotesList.tsx` | Reusable notes list + inline creation |
|
||||
| `src/components/dashboard/RecentActivityList.tsx` | Recent activity feed |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/components/dashboard/DeadlineTrafficLights.tsx` | Buttons → Links with navigation |
|
||||
| `src/components/dashboard/CaseOverviewGrid.tsx` | Static metrics → clickable links |
|
||||
| `src/components/dashboard/UpcomingTimeline.tsx` | Items → clickable with navigation |
|
||||
| `src/components/dashboard/AISummaryCard.tsx` | Add refresh button |
|
||||
| `src/components/dashboard/QuickActions.tsx` | Fix targets, swap CalDAV for Termin |
|
||||
| `src/app/(app)/dashboard/page.tsx` | Wire navigation, add RecentActivity section |
|
||||
| `src/app/(app)/fristen/page.tsx` | Read URL params for initial filter |
|
||||
| `src/app/(app)/cases/page.tsx` | Read URL params for initial filter |
|
||||
| `src/app/(app)/cases/[id]/page.tsx` | Refactor into layout + nested routes |
|
||||
| `src/lib/types.ts` | Add Note, RecentActivity types; update UpcomingDeadline |
|
||||
|
||||
### Types to Add
|
||||
|
||||
```typescript
|
||||
export interface Note {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
case_id?: string;
|
||||
deadline_id?: string;
|
||||
appointment_id?: string;
|
||||
case_event_id?: string;
|
||||
content: string;
|
||||
created_by?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RecentActivity {
|
||||
id: string;
|
||||
event_type?: string;
|
||||
title: string;
|
||||
case_id: string;
|
||||
case_number: string;
|
||||
event_date?: string;
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 7: Implementation Plan
|
||||
|
||||
Recommended order for a coder to implement:
|
||||
|
||||
### Phase A: Backend Foundation (can be done in parallel)
|
||||
1. Create `notes` table migration + model + service + handler
|
||||
2. Add `GET /api/deadlines/{id}` endpoint
|
||||
3. Add `GET /api/appointments/{id}` endpoint
|
||||
4. Add `GET /api/case-events/{id}` endpoint
|
||||
5. Fix dashboard query to include `case_id` in upcoming deadlines and recent activity
|
||||
|
||||
### Phase B: Frontend — Dashboard Interactivity
|
||||
1. Create `Breadcrumb` component
|
||||
2. Make traffic light cards clickable (Links)
|
||||
3. Make case overview grid clickable (Links)
|
||||
4. Make timeline items clickable (Links)
|
||||
5. Fix quick actions (swap CalDAV for Termin, update hrefs)
|
||||
6. Add refresh button to AI Summary card
|
||||
7. Add RecentActivityList component + wire to dashboard
|
||||
|
||||
### Phase C: Frontend — New Detail Pages
|
||||
1. Deadline detail page (`/fristen/[id]`)
|
||||
2. Appointment detail page (`/termine/[id]`)
|
||||
3. Case event detail page (`/cases/[id]/ereignisse/[eventId]`)
|
||||
4. Standalone deadline creation (`/fristen/neu`)
|
||||
5. Standalone appointment creation (`/termine/neu`)
|
||||
|
||||
### Phase D: Frontend — Case Detail Refactor
|
||||
1. Extract case header + tabs into layout.tsx
|
||||
2. Create sub-route pages (verlauf, fristen, dokumente, parteien)
|
||||
3. Add notes tab
|
||||
4. Wire `NotesList` component into all detail pages
|
||||
|
||||
### Phase E: Polish
|
||||
1. URL filter params on `/fristen` and `/cases` pages
|
||||
2. Breadcrumbs on all pages
|
||||
3. Mobile responsive testing
|
||||
4. Migration of existing `deadlines.notes` data
|
||||
|
||||
---
|
||||
|
||||
## Appendix: What This Design Does NOT Cover
|
||||
|
||||
- Real AI-powered summary (currently rule-based — kept as-is with refresh button)
|
||||
- Notification system (toast-based alerts for approaching deadlines)
|
||||
- Audit log / change history per entity
|
||||
- Batch operations (mark multiple deadlines complete)
|
||||
- Print views
|
||||
|
||||
These are separate features that can be designed independently.
|
||||
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:
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
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())
|
||||
|
||||
@@ -73,9 +73,31 @@ func createTestJWT(t *testing.T, userID uuid.UUID) string {
|
||||
return signed
|
||||
}
|
||||
|
||||
// createExpiredJWT creates an expired JWT for testing auth rejection.
|
||||
func createExpiredJWT(t *testing.T, userID uuid.UUID) string {
|
||||
t.Helper()
|
||||
secret := os.Getenv("SUPABASE_JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "test-jwt-secret-for-integration-tests"
|
||||
}
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"sub": userID.String(),
|
||||
"aud": "authenticated",
|
||||
"exp": time.Now().Add(-1 * time.Hour).Unix(),
|
||||
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
t.Fatalf("signing expired JWT: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
// setupTestTenant creates a temporary tenant and user_tenant for testing.
|
||||
// Returns tenantID and userID. Cleans up on test completion.
|
||||
func setupTestTenant(t *testing.T, handler http.Handler) (tenantID, userID uuid.UUID) {
|
||||
func setupTestTenant(t *testing.T, _ http.Handler) (tenantID, userID uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
@@ -127,6 +149,43 @@ func setupTestTenant(t *testing.T, handler http.Handler) (tenantID, userID uuid.
|
||||
return tenantID, userID
|
||||
}
|
||||
|
||||
// doRequest is a test helper that makes an HTTP request to the test handler.
|
||||
func doRequest(t *testing.T, handler http.Handler, method, path, token string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
var bodyReader *bytes.Buffer
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshaling request body: %v", err)
|
||||
}
|
||||
bodyReader = bytes.NewBuffer(b)
|
||||
} else {
|
||||
bodyReader = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, bodyReader)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// decodeJSON decodes the response body into the given target.
|
||||
func decodeJSON(t *testing.T, w *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(w.Body.Bytes(), target); err != nil {
|
||||
t.Fatalf("decoding JSON response: %v (body: %s)", err, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Health ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
@@ -146,6 +205,810 @@ func TestHealthEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth Middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestUnauthenticatedAccess(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/api/cases"},
|
||||
{"POST", "/api/cases"},
|
||||
{"GET", "/api/tenants"},
|
||||
{"GET", "/api/deadlines"},
|
||||
{"GET", "/api/appointments"},
|
||||
{"GET", "/api/dashboard"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
w := doRequest(t, handler, ep.method, ep.path, "", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiredJWT(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
token := createExpiredJWT(t, uuid.New())
|
||||
w := doRequest(t, handler, "GET", "/api/cases", token, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for expired token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidJWT(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
w := doRequest(t, handler, "GET", "/api/cases", "totally-not-a-jwt", nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for invalid token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongSecretJWT(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Sign with a different secret
|
||||
claims := jwt.MapClaims{
|
||||
"sub": uuid.New().String(),
|
||||
"exp": time.Now().Add(1 * time.Hour).Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, _ := token.SignedString([]byte("wrong-secret-key"))
|
||||
|
||||
w := doRequest(t, handler, "GET", "/api/cases", signed, nil)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for wrong-secret token, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tenant CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestTenantCRUD(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
// List tenants — should have the one from setup
|
||||
t.Run("ListTenants", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/tenants", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var tenants []map[string]any
|
||||
decodeJSON(t, w, &tenants)
|
||||
if len(tenants) < 1 {
|
||||
t.Fatalf("expected at least 1 tenant, got %d", len(tenants))
|
||||
}
|
||||
})
|
||||
|
||||
// Create a new tenant
|
||||
var newTenantID string
|
||||
t.Run("CreateTenant", func(t *testing.T) {
|
||||
body := map[string]string{"name": "Test Kanzlei Neu", "slug": "test-neu-" + uuid.New().String()[:8]}
|
||||
w := doRequest(t, handler, "POST", "/api/tenants", token, body)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
newTenantID, _ = resp["id"].(string)
|
||||
if newTenantID == "" {
|
||||
t.Fatal("created tenant missing ID")
|
||||
}
|
||||
|
||||
// Cleanup the extra tenant
|
||||
t.Cleanup(func() {
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
cleanupDB, _ := db.Connect(dbURL)
|
||||
if cleanupDB != nil {
|
||||
cleanupDB.Exec(`DELETE FROM user_tenants WHERE tenant_id = $1`, newTenantID)
|
||||
cleanupDB.Exec(`DELETE FROM tenants WHERE id = $1`, newTenantID)
|
||||
cleanupDB.Close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Get tenant by ID
|
||||
t.Run("GetTenant", func(t *testing.T) {
|
||||
if newTenantID == "" {
|
||||
t.Skip("no tenant ID from create test")
|
||||
}
|
||||
w := doRequest(t, handler, "GET", "/api/tenants/"+newTenantID, token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// Get tenant with invalid ID
|
||||
t.Run("GetTenant_InvalidID", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/tenants/not-a-uuid", token, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Create tenant missing fields
|
||||
t.Run("CreateTenant_MissingFields", func(t *testing.T) {
|
||||
body := map[string]string{"name": "", "slug": ""}
|
||||
w := doRequest(t, handler, "POST", "/api/tenants", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Case CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCaseCRUD(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
var caseID string
|
||||
|
||||
// Create case
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"case_number": "TEST/CRUD/001",
|
||||
"title": "CRUD Test — Patentverletzung",
|
||||
"case_type": "patent",
|
||||
"court": "UPC München",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/cases", token, body)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
caseID, _ = resp["id"].(string)
|
||||
if caseID == "" {
|
||||
t.Fatal("created case missing ID")
|
||||
}
|
||||
if resp["status"] != "active" {
|
||||
t.Errorf("expected default status active, got %v", resp["status"])
|
||||
}
|
||||
})
|
||||
|
||||
// Create case - missing fields
|
||||
t.Run("Create_MissingFields", func(t *testing.T) {
|
||||
body := map[string]string{"title": ""}
|
||||
w := doRequest(t, handler, "POST", "/api/cases", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// List cases
|
||||
t.Run("List", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
total := resp["total"].(float64)
|
||||
if total < 1 {
|
||||
t.Fatalf("expected at least 1 case, got %.0f", total)
|
||||
}
|
||||
})
|
||||
|
||||
// List with search filter
|
||||
t.Run("List_SearchFilter", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases?search=CRUD", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
total := resp["total"].(float64)
|
||||
if total < 1 {
|
||||
t.Error("search filter should find our test case")
|
||||
}
|
||||
})
|
||||
|
||||
// List with status filter
|
||||
t.Run("List_StatusFilter", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases?status=active", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Get case by ID
|
||||
t.Run("Get", func(t *testing.T) {
|
||||
if caseID == "" {
|
||||
t.Skip("no case ID")
|
||||
}
|
||||
w := doRequest(t, handler, "GET", "/api/cases/"+caseID, token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["title"] != "CRUD Test — Patentverletzung" {
|
||||
t.Errorf("unexpected title: %v", resp["title"])
|
||||
}
|
||||
// CaseDetail should include parties and recent_events
|
||||
if _, ok := resp["parties"]; !ok {
|
||||
t.Error("missing parties in CaseDetail response")
|
||||
}
|
||||
if _, ok := resp["recent_events"]; !ok {
|
||||
t.Error("missing recent_events in CaseDetail response")
|
||||
}
|
||||
})
|
||||
|
||||
// Get case - invalid ID
|
||||
t.Run("Get_InvalidID", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases/not-a-uuid", token, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Get case - not found
|
||||
t.Run("Get_NotFound", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases/"+uuid.New().String(), token, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Update case
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
if caseID == "" {
|
||||
t.Skip("no case ID")
|
||||
}
|
||||
body := map[string]string{"title": "Updated Title", "court": "UPC Paris"}
|
||||
w := doRequest(t, handler, "PUT", "/api/cases/"+caseID, token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["title"] != "Updated Title" {
|
||||
t.Errorf("title not updated: %v", resp["title"])
|
||||
}
|
||||
})
|
||||
|
||||
// Delete (archive) case
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
if caseID == "" {
|
||||
t.Skip("no case ID")
|
||||
}
|
||||
w := doRequest(t, handler, "DELETE", "/api/cases/"+caseID, token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]string
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["status"] != "archived" {
|
||||
t.Errorf("expected archived status, got %s", resp["status"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Deadline CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestDeadlineCRUD(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
// First create a case to attach deadlines to
|
||||
caseBody := map[string]string{
|
||||
"case_number": "TEST/DL/001",
|
||||
"title": "Deadline Test Case",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/cases", token, caseBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create case: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var caseResp map[string]any
|
||||
decodeJSON(t, w, &caseResp)
|
||||
caseID := caseResp["id"].(string)
|
||||
|
||||
var deadlineID string
|
||||
dueDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
||||
warnDate := time.Now().AddDate(0, 0, 10).Format("2006-01-02")
|
||||
|
||||
// Create deadline
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"title": "Klageerwiderung einreichen",
|
||||
"due_date": dueDate,
|
||||
"warning_date": warnDate,
|
||||
"source": "manual",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, body)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
deadlineID, _ = resp["id"].(string)
|
||||
if deadlineID == "" {
|
||||
t.Fatal("created deadline missing ID")
|
||||
}
|
||||
if resp["status"] != "pending" {
|
||||
t.Errorf("expected status pending, got %v", resp["status"])
|
||||
}
|
||||
})
|
||||
|
||||
// Create deadline - missing fields
|
||||
t.Run("Create_MissingFields", func(t *testing.T) {
|
||||
body := map[string]string{"title": ""}
|
||||
w := doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// List all deadlines
|
||||
t.Run("ListAll", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/deadlines", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// List deadlines for case
|
||||
t.Run("ListForCase", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/deadlines", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var deadlines []any
|
||||
decodeJSON(t, w, &deadlines)
|
||||
if len(deadlines) < 1 {
|
||||
t.Fatal("expected at least 1 deadline")
|
||||
}
|
||||
})
|
||||
|
||||
// Update deadline
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
if deadlineID == "" {
|
||||
t.Skip("no deadline ID")
|
||||
}
|
||||
newTitle := "Aktualisierte Frist"
|
||||
body := map[string]*string{"title": &newTitle}
|
||||
w := doRequest(t, handler, "PUT", "/api/deadlines/"+deadlineID, token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["title"] != newTitle {
|
||||
t.Errorf("expected title %q, got %v", newTitle, resp["title"])
|
||||
}
|
||||
})
|
||||
|
||||
// Complete deadline
|
||||
t.Run("Complete", func(t *testing.T) {
|
||||
if deadlineID == "" {
|
||||
t.Skip("no deadline ID")
|
||||
}
|
||||
w := doRequest(t, handler, "PATCH", "/api/deadlines/"+deadlineID+"/complete", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["status"] != "completed" {
|
||||
t.Errorf("expected status completed, got %v", resp["status"])
|
||||
}
|
||||
if resp["completed_at"] == nil {
|
||||
t.Error("expected completed_at to be set")
|
||||
}
|
||||
})
|
||||
|
||||
// Delete deadline
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
if deadlineID == "" {
|
||||
t.Skip("no deadline ID")
|
||||
}
|
||||
w := doRequest(t, handler, "DELETE", "/api/deadlines/"+deadlineID, token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// Delete non-existent deadline
|
||||
t.Run("Delete_NotFound", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "DELETE", "/api/deadlines/"+uuid.New().String(), token, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Appointment CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
func TestAppointmentCRUD(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
startAt := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Second)
|
||||
endAt := startAt.Add(2 * time.Hour)
|
||||
|
||||
var appointmentID string
|
||||
|
||||
// Create appointment
|
||||
t.Run("Create", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"title": "Mündliche Verhandlung",
|
||||
"start_at": startAt.Format(time.RFC3339),
|
||||
"end_at": endAt.Format(time.RFC3339),
|
||||
"location": "UPC München, Saal 3",
|
||||
"appointment_type": "hearing",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/appointments", token, body)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
appointmentID, _ = resp["id"].(string)
|
||||
if appointmentID == "" {
|
||||
t.Fatal("created appointment missing ID")
|
||||
}
|
||||
})
|
||||
|
||||
// Create appointment - missing title
|
||||
t.Run("Create_MissingTitle", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"start_at": startAt.Format(time.RFC3339),
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/appointments", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// Create appointment - missing start_at
|
||||
t.Run("Create_MissingStartAt", func(t *testing.T) {
|
||||
body := map[string]any{"title": "Test"}
|
||||
w := doRequest(t, handler, "POST", "/api/appointments", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
// List appointments
|
||||
t.Run("List", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/appointments", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var appointments []any
|
||||
decodeJSON(t, w, &appointments)
|
||||
if len(appointments) < 1 {
|
||||
t.Fatal("expected at least 1 appointment")
|
||||
}
|
||||
})
|
||||
|
||||
// Update appointment
|
||||
t.Run("Update", func(t *testing.T) {
|
||||
if appointmentID == "" {
|
||||
t.Skip("no appointment ID")
|
||||
}
|
||||
newStart := startAt.Add(24 * time.Hour)
|
||||
body := map[string]any{
|
||||
"title": "Verlegter Termin",
|
||||
"start_at": newStart.Format(time.RFC3339),
|
||||
"location": "UPC Paris, Saal 1",
|
||||
}
|
||||
w := doRequest(t, handler, "PUT", "/api/appointments/"+appointmentID, token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["title"] != "Verlegter Termin" {
|
||||
t.Errorf("title not updated: %v", resp["title"])
|
||||
}
|
||||
})
|
||||
|
||||
// Delete appointment
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
if appointmentID == "" {
|
||||
t.Skip("no appointment ID")
|
||||
}
|
||||
w := doRequest(t, handler, "DELETE", "/api/appointments/"+appointmentID, token, nil)
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
// Delete non-existent appointment
|
||||
t.Run("Delete_NotFound", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "DELETE", "/api/appointments/"+uuid.New().String(), token, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Dashboard ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestDashboard(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
// Create a case + deadline so dashboard has data
|
||||
caseBody := map[string]string{
|
||||
"case_number": "TEST/DASH/001",
|
||||
"title": "Dashboard Test Case",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/cases", token, caseBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create case: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var caseResp map[string]any
|
||||
decodeJSON(t, w, &caseResp)
|
||||
caseID := caseResp["id"].(string)
|
||||
|
||||
dueDate := time.Now().AddDate(0, 0, 3).Format("2006-01-02")
|
||||
dlBody := map[string]string{
|
||||
"title": "Dashboard Test Deadline",
|
||||
"due_date": dueDate,
|
||||
}
|
||||
w = doRequest(t, handler, "POST", "/api/cases/"+caseID+"/deadlines", token, dlBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create deadline: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
t.Run("Get", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/dashboard", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var dashboard map[string]any
|
||||
decodeJSON(t, w, &dashboard)
|
||||
|
||||
// Verify all expected sections exist
|
||||
requiredKeys := []string{"deadline_summary", "case_summary", "upcoming_deadlines", "upcoming_appointments", "recent_activity"}
|
||||
for _, key := range requiredKeys {
|
||||
if _, exists := dashboard[key]; !exists {
|
||||
t.Errorf("missing key %q in dashboard", key)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify case_summary has at least 1 active case
|
||||
if cs, ok := dashboard["case_summary"].(map[string]any); ok {
|
||||
if active, ok := cs["active_count"].(float64); ok && active < 1 {
|
||||
t.Errorf("expected at least 1 active case, got %.0f", active)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify deadline_summary fields
|
||||
if ds, ok := dashboard["deadline_summary"].(map[string]any); ok {
|
||||
for _, key := range []string{"overdue_count", "due_this_week", "due_next_week", "ok_count"} {
|
||||
if _, exists := ds[key]; !exists {
|
||||
t.Errorf("missing deadline_summary.%s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Deadline Calculator ─────────────────────────────────────────────────────
|
||||
|
||||
func TestDeadlineCalculator(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
t.Run("Calculate_ValidRequest", func(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"proceeding_type": "INF",
|
||||
"trigger_event_date": "2026-06-01",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
if resp["proceeding_type"] != "INF" {
|
||||
t.Errorf("expected INF, got %v", resp["proceeding_type"])
|
||||
}
|
||||
deadlines, ok := resp["deadlines"].([]any)
|
||||
if !ok {
|
||||
t.Fatal("deadlines not an array")
|
||||
}
|
||||
if len(deadlines) == 0 {
|
||||
t.Error("expected at least 1 calculated deadline")
|
||||
}
|
||||
|
||||
// Verify each deadline has expected fields
|
||||
for i, dl := range deadlines {
|
||||
m, ok := dl.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"rule_code", "title", "due_date"} {
|
||||
if _, exists := m[key]; !exists {
|
||||
t.Errorf("deadline[%d] missing field %q", i, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Calculate_MissingFields", func(t *testing.T) {
|
||||
body := map[string]string{}
|
||||
w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Calculate_InvalidDateFormat", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"proceeding_type": "INF",
|
||||
"trigger_event_date": "01/06/2026",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for bad date format, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Calculate_UnknownProceedingType", func(t *testing.T) {
|
||||
body := map[string]string{
|
||||
"proceeding_type": "NONEXISTENT",
|
||||
"trigger_event_date": "2026-06-01",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/deadlines/calculate", token, body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown type, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Proceeding Types & Deadline Rules ───────────────────────────────────────
|
||||
|
||||
func TestProceedingTypesAndRules(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
t.Run("ListProceedingTypes", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/proceeding-types", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var types []any
|
||||
decodeJSON(t, w, &types)
|
||||
if len(types) == 0 {
|
||||
t.Error("expected at least 1 proceeding type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListDeadlineRules", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/deadline-rules", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetRuleTree_INF", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/deadline-rules/INF", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var tree []any
|
||||
decodeJSON(t, w, &tree)
|
||||
if len(tree) == 0 {
|
||||
t.Error("expected non-empty rule tree for INF")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Document Upload ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestDocumentUpload(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
// Create a case first
|
||||
caseBody := map[string]string{
|
||||
"case_number": "TEST/DOC/001",
|
||||
"title": "Document Test Case",
|
||||
}
|
||||
w := doRequest(t, handler, "POST", "/api/cases", token, caseBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create case: %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var caseResp map[string]any
|
||||
decodeJSON(t, w, &caseResp)
|
||||
caseID := caseResp["id"].(string)
|
||||
|
||||
t.Run("ListDocuments_Empty", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/cases/"+caseID+"/documents", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]any
|
||||
decodeJSON(t, w, &resp)
|
||||
total := resp["total"].(float64)
|
||||
if total != 0 {
|
||||
t.Errorf("expected 0 documents, got %.0f", total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetMeta_NotFound", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/documents/"+uuid.New().String()+"/meta", token, nil)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetMeta_InvalidID", func(t *testing.T) {
|
||||
w := doRequest(t, handler, "GET", "/api/documents/not-a-uuid/meta", token, nil)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── AI Endpoints ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestAIExtractDeadlines_NoAPIKey(t *testing.T) {
|
||||
// AI endpoints are only registered when ANTHROPIC_API_KEY is set.
|
||||
// Without it, the route should 404.
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
_, userID := setupTestTenant(t, handler)
|
||||
token := createTestJWT(t, userID)
|
||||
|
||||
body := map[string]string{"text": "Die Frist beträgt 3 Monate ab Zustellung."}
|
||||
w := doRequest(t, handler, "POST", "/api/ai/extract-deadlines", token, body)
|
||||
|
||||
// Should be 404 (route not registered) since we don't set ANTHROPIC_API_KEY
|
||||
// or 200 if the key happens to be set in the env
|
||||
if w.Code == http.StatusNotFound {
|
||||
t.Log("AI endpoint correctly not available without API key")
|
||||
} else if w.Code == http.StatusOK {
|
||||
t.Log("AI endpoint available (API key configured in env)")
|
||||
} else {
|
||||
t.Logf("AI endpoint returned %d (may need API key)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Critical Path E2E ───────────────────────────────────────────────────────
|
||||
|
||||
func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
@@ -165,7 +1028,7 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
t.Fatalf("create case: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var createdCase map[string]interface{}
|
||||
var createdCase map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &createdCase)
|
||||
caseID, ok := createdCase["id"].(string)
|
||||
if !ok || caseID == "" {
|
||||
@@ -183,7 +1046,7 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
t.Fatalf("list cases: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var caseList map[string]interface{}
|
||||
var caseList map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &caseList)
|
||||
total := caseList["total"].(float64)
|
||||
if total < 1 {
|
||||
@@ -204,7 +1067,7 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
t.Fatalf("create deadline: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var createdDeadline map[string]interface{}
|
||||
var createdDeadline map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &createdDeadline)
|
||||
deadlineID, ok := createdDeadline["id"].(string)
|
||||
if !ok || deadlineID == "" {
|
||||
@@ -212,7 +1075,22 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
}
|
||||
t.Logf("Created deadline: %s", deadlineID)
|
||||
|
||||
// Step 4: Verify deadline appears in case deadlines
|
||||
// Step 4: Create an appointment linked to the case
|
||||
startAt := time.Now().Add(72 * time.Hour).UTC().Truncate(time.Second)
|
||||
apptBody := map[string]any{
|
||||
"title": "Mündliche Verhandlung",
|
||||
"start_at": startAt.Format(time.RFC3339),
|
||||
"location": "UPC München",
|
||||
"appointment_type": "hearing",
|
||||
"case_id": caseID,
|
||||
}
|
||||
w = doRequest(t, handler, "POST", "/api/appointments", token, apptBody)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create appointment: expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
t.Log("Created appointment linked to case")
|
||||
|
||||
// Step 5: Verify deadline appears in case deadlines
|
||||
req = httptest.NewRequest("GET", "/api/cases/"+caseID+"/deadlines", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w = httptest.NewRecorder()
|
||||
@@ -222,13 +1100,13 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
t.Fatalf("list deadlines: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var deadlines []interface{}
|
||||
var deadlines []any
|
||||
json.Unmarshal(w.Body.Bytes(), &deadlines)
|
||||
if len(deadlines) < 1 {
|
||||
t.Fatalf("list deadlines: expected at least 1, got %d", len(deadlines))
|
||||
}
|
||||
|
||||
// Step 5: Fetch dashboard — should include our case and deadline
|
||||
// Step 6: Fetch dashboard — should include our case and deadline
|
||||
req = httptest.NewRequest("GET", "/api/dashboard", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w = httptest.NewRecorder()
|
||||
@@ -238,7 +1116,7 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
t.Fatalf("dashboard: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var dashboard map[string]interface{}
|
||||
var dashboard map[string]any
|
||||
json.Unmarshal(w.Body.Bytes(), &dashboard)
|
||||
|
||||
// Verify dashboard has expected sections
|
||||
@@ -249,25 +1127,22 @@ func TestCriticalPath_CreateCase_AddDeadline_Dashboard(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify case_summary shows at least 1 active case
|
||||
if cs, ok := dashboard["case_summary"].(map[string]interface{}); ok {
|
||||
if active, ok := cs["active"].(float64); ok && active < 1 {
|
||||
if cs, ok := dashboard["case_summary"].(map[string]any); ok {
|
||||
if active, ok := cs["active_count"].(float64); ok && active < 1 {
|
||||
t.Errorf("dashboard: expected at least 1 active case, got %.0f", active)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Full critical path passed: auth -> create case -> add deadline -> dashboard")
|
||||
}
|
||||
|
||||
func TestUnauthenticatedAccess(t *testing.T) {
|
||||
handler, cleanup := testServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Accessing API without token should return 401
|
||||
req := httptest.NewRequest("GET", "/api/cases", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String())
|
||||
// Step 7: Complete the deadline and verify
|
||||
w = doRequest(t, handler, "PATCH", "/api/deadlines/"+deadlineID+"/complete", token, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("complete deadline: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var completedDL map[string]any
|
||||
decodeJSON(t, w, &completedDL)
|
||||
if completedDL["status"] != "completed" {
|
||||
t.Errorf("expected completed, got %v", completedDL["status"])
|
||||
}
|
||||
|
||||
t.Logf("Full critical path passed: auth -> create case -> add deadline -> create appointment -> dashboard -> complete deadline")
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,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:
|
||||
|
||||
0
frontend/.m/spawn.lock
Normal file
0
frontend/.m/spawn.lock
Normal file
@@ -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,10 +24,10 @@ 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 ?? [];
|
||||
const cases = Array.isArray(casesData?.data) ? casesData.data : [];
|
||||
|
||||
async function handleExtract(file: File | null, text: string) {
|
||||
setIsExtracting(true);
|
||||
@@ -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",
|
||||
|
||||
@@ -132,8 +132,8 @@ export default function CaseDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const deadlines = deadlinesData?.deadlines ?? [];
|
||||
const documents = documentsData ?? [];
|
||||
const deadlines = Array.isArray(deadlinesData?.deadlines) ? deadlinesData.deadlines : [];
|
||||
const documents = Array.isArray(documentsData) ? documentsData : [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -205,7 +205,7 @@ export default function CaseDetailPage() {
|
||||
{caseDetail.deadlines_count}
|
||||
</span>
|
||||
)}
|
||||
{tab.key === "parties" && caseDetail.parties.length > 0 && (
|
||||
{tab.key === "parties" && Array.isArray(caseDetail.parties) && caseDetail.parties.length > 0 && (
|
||||
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
|
||||
{caseDetail.parties.length}
|
||||
</span>
|
||||
@@ -217,7 +217,7 @@ export default function CaseDetailPage() {
|
||||
|
||||
<div className="mt-6">
|
||||
{activeTab === "timeline" && (
|
||||
<CaseTimeline events={caseDetail.recent_events ?? []} />
|
||||
<CaseTimeline events={Array.isArray(caseDetail.recent_events) ? caseDetail.recent_events : []} />
|
||||
)}
|
||||
|
||||
{activeTab === "deadlines" && (
|
||||
@@ -229,7 +229,7 @@ export default function CaseDetailPage() {
|
||||
)}
|
||||
|
||||
{activeTab === "parties" && (
|
||||
<PartyList caseId={id} parties={caseDetail.parties ?? []} />
|
||||
<PartyList caseId={id} parties={Array.isArray(caseDetail.parties) ? caseDetail.parties : []} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function CasesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const cases = data?.cases ?? [];
|
||||
const cases = Array.isArray(data?.cases) ? data.cases : [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
|
||||
@@ -80,17 +80,17 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DeadlineTrafficLights data={data.deadline_summary} />
|
||||
<DeadlineTrafficLights data={data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 }} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<UpcomingTimeline
|
||||
deadlines={data.upcoming_deadlines}
|
||||
appointments={data.upcoming_appointments}
|
||||
deadlines={Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : []}
|
||||
appointments={Array.isArray(data.upcoming_appointments) ? data.upcoming_appointments : []}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<CaseOverviewGrid data={data.case_summary} />
|
||||
<CaseOverviewGrid data={data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 }} />
|
||||
<AISummaryCard data={data} />
|
||||
<QuickActions />
|
||||
</div>
|
||||
|
||||
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>(`/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 (
|
||||
@@ -66,7 +66,7 @@ export default function FristenPage() {
|
||||
{view === "list" ? (
|
||||
<DeadlineList />
|
||||
) : (
|
||||
<DeadlineCalendarView deadlines={deadlines || []} />
|
||||
<DeadlineCalendarView deadlines={Array.isArray(deadlines) ? deadlines : []} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ export default function TerminePage() {
|
||||
|
||||
const { data: appointments } = useQuery({
|
||||
queryKey: ["appointments"],
|
||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
||||
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||
});
|
||||
|
||||
function handleEdit(appointment: Appointment) {
|
||||
@@ -84,7 +84,7 @@ export default function TerminePage() {
|
||||
<AppointmentList onEdit={handleEdit} />
|
||||
) : (
|
||||
<AppointmentCalendar
|
||||
appointments={appointments || []}
|
||||
appointments={Array.isArray(appointments) ? appointments : []}
|
||||
onAppointmentClick={handleEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -54,16 +54,16 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
||||
|
||||
const { data: appointments, isLoading } = useQuery({
|
||||
queryKey: ["appointments"],
|
||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
||||
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||
});
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/appointments/${id}`),
|
||||
mutationFn: (id: string) => api.delete(`/appointments/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
toast.success("Termin geloscht");
|
||||
@@ -73,12 +73,13 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
||||
|
||||
const caseMap = useMemo(() => {
|
||||
const map = new Map<string, Case>();
|
||||
cases?.cases?.forEach((c) => map.set(c.id, c));
|
||||
const arr = Array.isArray(cases?.cases) ? cases.cases : [];
|
||||
arr.forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [cases]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!appointments) return [];
|
||||
if (!Array.isArray(appointments)) return [];
|
||||
return appointments
|
||||
.filter((a) => {
|
||||
if (caseFilter !== "all" && a.case_id !== caseFilter) return false;
|
||||
@@ -91,7 +92,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
||||
const grouped = useMemo(() => groupByDate(filtered), [filtered]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
if (!appointments) return { today: 0, thisWeek: 0, total: 0 };
|
||||
if (!Array.isArray(appointments)) return { today: 0, thisWeek: 0, total: 0 };
|
||||
let today = 0;
|
||||
let thisWeek = 0;
|
||||
for (const a of appointments) {
|
||||
@@ -148,7 +149,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{cases?.cases && cases.cases.length > 0 && (
|
||||
{Array.isArray(cases?.cases) && cases.cases.length > 0 && (
|
||||
<select
|
||||
value={caseFilter}
|
||||
onChange={(e) => setCaseFilter(e.target.value)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
|
||||
const { data: cases } = useQuery({
|
||||
queryKey: ["cases"],
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,7 +66,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post<Appointment>("/api/appointments", body),
|
||||
api.post<Appointment>("/appointments", body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
@@ -78,7 +78,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.put<Appointment>(`/api/appointments/${appointment!.id}`, body),
|
||||
api.put<Appointment>(`/appointments/${appointment!.id}`, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
@@ -89,7 +89,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/appointments/${appointment!.id}`),
|
||||
mutationFn: () => api.delete(`/appointments/${appointment!.id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
|
||||
@@ -9,7 +9,9 @@ interface Props {
|
||||
|
||||
function generateSummary(data: DashboardData): string {
|
||||
const parts: string[] = [];
|
||||
const { deadline_summary: ds, case_summary: cs, upcoming_deadlines: ud } = data;
|
||||
const ds = data.deadline_summary ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
|
||||
const cs = data.case_summary ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
|
||||
const ud = Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : [];
|
||||
|
||||
// Deadline urgency
|
||||
if (ds.overdue_count > 0) {
|
||||
|
||||
@@ -8,24 +8,25 @@ interface Props {
|
||||
}
|
||||
|
||||
export function CaseOverviewGrid({ data }: Props) {
|
||||
const safe = data ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
|
||||
const items = [
|
||||
{
|
||||
label: "Aktive Akten",
|
||||
value: data.active_count,
|
||||
value: safe.active_count ?? 0,
|
||||
icon: FolderOpen,
|
||||
color: "text-blue-600",
|
||||
bg: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
label: "Neu (Monat)",
|
||||
value: data.new_this_month,
|
||||
value: safe.new_this_month ?? 0,
|
||||
icon: FolderPlus,
|
||||
color: "text-violet-600",
|
||||
bg: "bg-violet-50",
|
||||
},
|
||||
{
|
||||
label: "Abgeschlossen",
|
||||
value: data.closed_count,
|
||||
value: safe.closed_count ?? 0,
|
||||
icon: Archive,
|
||||
color: "text-neutral-500",
|
||||
bg: "bg-neutral-50",
|
||||
|
||||
@@ -31,24 +31,25 @@ interface Props {
|
||||
}
|
||||
|
||||
export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
||||
const safe = data ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
|
||||
const cards = [
|
||||
{
|
||||
key: "overdue" as const,
|
||||
label: "Überfällig",
|
||||
count: data.overdue_count,
|
||||
count: safe.overdue_count ?? 0,
|
||||
icon: AlertTriangle,
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-200",
|
||||
iconColor: "text-red-500",
|
||||
countColor: "text-red-700",
|
||||
labelColor: "text-red-600",
|
||||
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
|
||||
pulse: data.overdue_count > 0,
|
||||
ring: (safe.overdue_count ?? 0) > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
|
||||
pulse: (safe.overdue_count ?? 0) > 0,
|
||||
},
|
||||
{
|
||||
key: "this_week" as const,
|
||||
label: "Diese Woche",
|
||||
count: data.due_this_week,
|
||||
count: safe.due_this_week ?? 0,
|
||||
icon: Clock,
|
||||
bg: "bg-amber-50",
|
||||
border: "border-amber-200",
|
||||
@@ -61,7 +62,7 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
||||
{
|
||||
key: "ok" as const,
|
||||
label: "Im Zeitplan",
|
||||
count: data.ok_count + data.due_next_week,
|
||||
count: (safe.ok_count ?? 0) + (safe.due_next_week ?? 0),
|
||||
icon: CheckCircle,
|
||||
bg: "bg-emerald-50",
|
||||
border: "border-emerald-200",
|
||||
|
||||
@@ -21,13 +21,16 @@ function formatDayLabel(date: Date): string {
|
||||
}
|
||||
|
||||
export function UpcomingTimeline({ deadlines, appointments }: Props) {
|
||||
const safeDeadlines = Array.isArray(deadlines) ? deadlines : [];
|
||||
const safeAppointments = Array.isArray(appointments) ? appointments : [];
|
||||
|
||||
const items: TimelineItem[] = [
|
||||
...deadlines.map((d) => ({
|
||||
...safeDeadlines.map((d) => ({
|
||||
type: "deadline" as const,
|
||||
date: parseISO(d.due_date),
|
||||
data: d,
|
||||
})),
|
||||
...appointments.map((a) => ({
|
||||
...safeAppointments.map((a) => ({
|
||||
type: "appointment" as const,
|
||||
date: parseISO(a.start_at),
|
||||
data: a,
|
||||
|
||||
@@ -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,17 +54,17 @@ 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({
|
||||
mutationFn: (id: string) =>
|
||||
api.patch<Deadline>(`/api/deadlines/${id}/complete`),
|
||||
api.patch<Deadline>(`/deadlines/${id}/complete`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||
toast.success("Frist als erledigt markiert");
|
||||
@@ -76,12 +76,12 @@ export function DeadlineList() {
|
||||
|
||||
const caseMap = useMemo(() => {
|
||||
const map = new Map<string, Case>();
|
||||
cases?.forEach((c) => map.set(c.id, c));
|
||||
(Array.isArray(cases) ? cases : []).forEach((c) => map.set(c.id, c));
|
||||
return map;
|
||||
}, [cases]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!deadlines) return [];
|
||||
if (!Array.isArray(deadlines)) return [];
|
||||
return deadlines.filter((d) => {
|
||||
if (statusFilter === "pending" && d.status !== "pending") return false;
|
||||
if (statusFilter === "completed" && d.status !== "completed")
|
||||
@@ -96,7 +96,7 @@ export function DeadlineList() {
|
||||
}, [deadlines, statusFilter, caseFilter]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
if (!deadlines) return { overdue: 0, thisWeek: 0, ok: 0 };
|
||||
if (!Array.isArray(deadlines)) return { overdue: 0, thisWeek: 0, ok: 0 };
|
||||
let overdue = 0,
|
||||
thisWeek = 0,
|
||||
ok = 0;
|
||||
@@ -188,7 +188,7 @@ export function DeadlineList() {
|
||||
<option value="completed">Erledigt</option>
|
||||
<option value="overdue">Überfällig</option>
|
||||
</select>
|
||||
{cases && cases.length > 0 && (
|
||||
{Array.isArray(cases) && cases.length > 0 && (
|
||||
<select
|
||||
value={caseFilter}
|
||||
onChange={(e) => setCaseFilter(e.target.value)}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function TenantSwitcher() {
|
||||
api
|
||||
.get<TenantWithRole[]>("/tenants")
|
||||
.then((data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
setTenants(data);
|
||||
const savedId = localStorage.getItem("kanzlai_tenant_id");
|
||||
const match = data.find((t) => t.id === savedId) || data[0];
|
||||
@@ -53,30 +54,33 @@ export function TenantSwitcher() {
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2.5 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<span className="max-w-[120px] truncate sm:max-w-[160px]">
|
||||
<span className="max-w-[200px] truncate sm:max-w-[280px]">
|
||||
{current.name}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
|
||||
</button>
|
||||
|
||||
{open && tenants.length > 1 && (
|
||||
<div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
|
||||
{open && (
|
||||
<div className="animate-fade-in absolute right-0 top-full z-50 mt-1 w-64 rounded-md border border-neutral-200 bg-white py-1 shadow-lg">
|
||||
{tenants.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => switchTenant(tenant)}
|
||||
className={`flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors ${
|
||||
className={`flex w-full items-center px-3 py-2 text-left text-sm transition-colors ${
|
||||
tenant.id === current.id
|
||||
? "bg-neutral-50 font-medium text-neutral-900"
|
||||
: "text-neutral-600 hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{tenant.name}</span>
|
||||
<span className="ml-auto text-xs text-neutral-400">
|
||||
<span className="ml-auto shrink-0 text-xs text-neutral-400">
|
||||
{tenant.role}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{tenants.length <= 1 && (
|
||||
<p className="px-3 py-2 text-xs text-neutral-400">Einzige Kanzlei</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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>(`/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[]>(`/tenants/${tenantId}/members`),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (data: { email: string; role: string }) =>
|
||||
api.post(`/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(`/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 */}
|
||||
{Array.isArray(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