Compare commits
24 Commits
mai/knuth/
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac04930667 | ||
|
|
7c7ae396f4 | ||
|
|
433a0408f2 | ||
|
|
cabea83784 | ||
|
|
8863878b39 | ||
|
|
84b178edbf | ||
|
|
7094212dcf | ||
|
|
9787450d91 | ||
|
|
1e88dffd82 | ||
|
|
9ad58e1ba3 | ||
|
|
0712d9a367 | ||
|
|
cd31e76d07 | ||
|
|
f42b7ddec7 | ||
|
|
50bfa3deb4 | ||
|
|
e635efa71e | ||
|
|
12e0407025 | ||
|
|
325fbeb5de | ||
|
|
19bea8d058 | ||
|
|
661135d137 | ||
|
|
f8d97546e9 | ||
|
|
45605c803b | ||
|
|
e57b7c48ed | ||
|
|
c5c3f41e08 | ||
|
|
d0197a091c |
@@ -3,11 +3,16 @@
|
|||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/dbname
|
||||||
|
|
||||||
# Supabase (required for database access)
|
# Supabase (required for database + auth)
|
||||||
SUPABASE_URL=
|
SUPABASE_URL=https://your-project.supabase.co
|
||||||
SUPABASE_ANON_KEY=
|
SUPABASE_ANON_KEY=
|
||||||
SUPABASE_SERVICE_KEY=
|
SUPABASE_SERVICE_KEY=
|
||||||
|
SUPABASE_JWT_SECRET=
|
||||||
|
|
||||||
# Claude API (required for AI features)
|
# Claude API (required for AI features)
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# CalDAV (configured per-tenant in tenant settings, not env vars)
|
||||||
|
# See tenant.settings.caldav JSON field
|
||||||
|
|||||||
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 ./...
|
cd backend && go test ./...
|
||||||
|
|
||||||
test-frontend:
|
test-frontend:
|
||||||
@echo "No frontend tests configured yet"
|
cd frontend && bun run test
|
||||||
|
|
||||||
# Clean
|
# Clean
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
1321
ROADMAP.md
Normal file
1321
ROADMAP.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,31 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/db"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/logging"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/router"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
logging.Setup()
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
slog.Error("failed to load config", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
database, err := db.Connect(cfg.DatabaseURL)
|
database, err := db.Connect(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to connect to database: %v", err)
|
slog.Error("failed to connect to database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
@@ -32,8 +38,9 @@ func main() {
|
|||||||
|
|
||||||
handler := router.New(database, authMW, cfg, calDAVSvc)
|
handler := router.New(database, authMW, cfg, calDAVSvc)
|
||||||
|
|
||||||
log.Printf("Starting KanzlAI API server on :%s", cfg.Port)
|
slog.Info("starting KanzlAI API server", "port", cfg.Port)
|
||||||
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
if err := http.ListenAndServe(":"+cfg.Port, handler); err != nil {
|
||||||
log.Fatal(err)
|
slog.Error("server failed", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,33 @@ func NewAppointmentHandler(svc *services.AppointmentService) *AppointmentHandler
|
|||||||
return &AppointmentHandler{svc: svc}
|
return &AppointmentHandler{svc: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/appointments/{id}
|
||||||
|
func (h *AppointmentHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid appointment id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appt, err := h.svc.GetByID(r.Context(), tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "appointment not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch appointment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, appt)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *AppointmentHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, ok := auth.TenantFromContext(r.Context())
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/internal/handlers/case_events.go
Normal file
52
backend/internal/handlers/case_events.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CaseEventHandler struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaseEventHandler(db *sqlx.DB) *CaseEventHandler {
|
||||||
|
return &CaseEventHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/case-events/{id}
|
||||||
|
func (h *CaseEventHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eventID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid event ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var event models.CaseEvent
|
||||||
|
err = h.db.GetContext(r.Context(), &event,
|
||||||
|
`SELECT id, tenant_id, case_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at
|
||||||
|
FROM case_events
|
||||||
|
WHERE id = $1 AND tenant_id = $2`, eventID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeError(w, http.StatusNotFound, "case event not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch case event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, event)
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,33 @@ func NewDeadlineHandlers(ds *services.DeadlineService, db *sqlx.DB) *DeadlineHan
|
|||||||
return &DeadlineHandlers{deadlines: ds, db: db}
|
return &DeadlineHandlers{deadlines: ds, db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get handles GET /api/deadlines/{deadlineID}
|
||||||
|
func (h *DeadlineHandlers) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, err := resolveTenant(r, h.db)
|
||||||
|
if err != nil {
|
||||||
|
handleTenantError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deadlineID, err := parsePathUUID(r, "deadlineID")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid deadline ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, err := h.deadlines.GetByID(tenantID, deadlineID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to fetch deadline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deadline == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "deadline not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, deadline)
|
||||||
|
}
|
||||||
|
|
||||||
// ListAll handles GET /api/deadlines
|
// ListAll handles GET /api/deadlines
|
||||||
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
func (h *DeadlineHandlers) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||||
tenantID, err := resolveTenant(r, h.db)
|
tenantID, err := resolveTenant(r, h.db)
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
159
backend/internal/handlers/notes.go
Normal file
159
backend/internal/handlers/notes.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoteHandler struct {
|
||||||
|
svc *services.NoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNoteHandler(svc *services.NoteService) *NoteHandler {
|
||||||
|
return &NoteHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /api/notes?{parent_type}_id={id}
|
||||||
|
func (h *NoteHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parentType, parentID, err := parseNoteParent(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notes, err := h.svc.ListByParent(r.Context(), tenantID, parentType, parentID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list notes")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /api/notes
|
||||||
|
func (h *NoteHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userID, _ := auth.UserFromContext(r.Context())
|
||||||
|
|
||||||
|
var input services.CreateNoteInput
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if input.Content == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var createdBy *uuid.UUID
|
||||||
|
if userID != uuid.Nil {
|
||||||
|
createdBy = &userID
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := h.svc.Create(r.Context(), tenantID, createdBy, input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles PUT /api/notes/{id}
|
||||||
|
func (h *NoteHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
noteID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid note ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Content == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "content is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
note, err := h.svc.Update(r.Context(), tenantID, noteID, req.Content)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update note")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if note == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "note not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles DELETE /api/notes/{id}
|
||||||
|
func (h *NoteHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "missing tenant")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
noteID, err := uuid.Parse(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid note ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.svc.Delete(r.Context(), tenantID, noteID); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "note not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseNoteParent extracts the parent type and ID from query parameters.
|
||||||
|
func parseNoteParent(r *http.Request) (string, uuid.UUID, error) {
|
||||||
|
params := map[string]string{
|
||||||
|
"case_id": "case",
|
||||||
|
"deadline_id": "deadline",
|
||||||
|
"appointment_id": "appointment",
|
||||||
|
"case_event_id": "case_event",
|
||||||
|
}
|
||||||
|
|
||||||
|
for param, parentType := range params {
|
||||||
|
if v := r.URL.Query().Get(param); v != "" {
|
||||||
|
id, err := uuid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", uuid.Nil, fmt.Errorf("invalid %s", param)
|
||||||
|
}
|
||||||
|
return parentType, id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", uuid.Nil, fmt.Errorf("one of case_id, deadline_id, appointment_id, or case_event_id is required")
|
||||||
|
}
|
||||||
1148
backend/internal/integration_test.go
Normal file
1148
backend/internal/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
14
backend/internal/logging/logging.go
Normal file
14
backend/internal/logging/logging.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package logging
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup initializes the global slog logger with JSON output for production.
|
||||||
|
func Setup() {
|
||||||
|
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelInfo,
|
||||||
|
})
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
}
|
||||||
98
backend/internal/middleware/ratelimit.go
Normal file
98
backend/internal/middleware/ratelimit.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenBucket implements a simple per-IP token bucket rate limiter.
|
||||||
|
type TokenBucket struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buckets map[string]*bucket
|
||||||
|
rate float64 // tokens per second
|
||||||
|
burst int // max tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
type bucket struct {
|
||||||
|
tokens float64
|
||||||
|
lastTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTokenBucket creates a rate limiter allowing rate requests per second with burst capacity.
|
||||||
|
func NewTokenBucket(rate float64, burst int) *TokenBucket {
|
||||||
|
tb := &TokenBucket{
|
||||||
|
buckets: make(map[string]*bucket),
|
||||||
|
rate: rate,
|
||||||
|
burst: burst,
|
||||||
|
}
|
||||||
|
// Periodically clean up stale buckets
|
||||||
|
go tb.cleanup()
|
||||||
|
return tb
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *TokenBucket) allow(key string) bool {
|
||||||
|
tb.mu.Lock()
|
||||||
|
defer tb.mu.Unlock()
|
||||||
|
|
||||||
|
b, ok := tb.buckets[key]
|
||||||
|
if !ok {
|
||||||
|
b = &bucket{tokens: float64(tb.burst), lastTime: time.Now()}
|
||||||
|
tb.buckets[key] = b
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(b.lastTime).Seconds()
|
||||||
|
b.tokens += elapsed * tb.rate
|
||||||
|
if b.tokens > float64(tb.burst) {
|
||||||
|
b.tokens = float64(tb.burst)
|
||||||
|
}
|
||||||
|
b.lastTime = now
|
||||||
|
|
||||||
|
if b.tokens < 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
b.tokens--
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *TokenBucket) cleanup() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
tb.mu.Lock()
|
||||||
|
cutoff := time.Now().Add(-10 * time.Minute)
|
||||||
|
for key, b := range tb.buckets {
|
||||||
|
if b.lastTime.Before(cutoff) {
|
||||||
|
delete(tb.buckets, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tb.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit wraps an http.Handler with rate limiting.
|
||||||
|
func (tb *TokenBucket) Limit(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := r.Header.Get("X-Forwarded-For")
|
||||||
|
if ip == "" {
|
||||||
|
ip = r.RemoteAddr
|
||||||
|
}
|
||||||
|
if !tb.allow(ip) {
|
||||||
|
slog.Warn("rate limit exceeded", "ip", ip, "path", r.URL.Path)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Retry-After", "10")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
w.Write([]byte(`{"error":"rate limit exceeded, try again later"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LimitFunc wraps an http.HandlerFunc with rate limiting.
|
||||||
|
func (tb *TokenBucket) LimitFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
limited := tb.Limit(http.HandlerFunc(next))
|
||||||
|
return limited.ServeHTTP
|
||||||
|
}
|
||||||
70
backend/internal/middleware/ratelimit_test.go
Normal file
70
backend/internal/middleware/ratelimit_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenBucket_AllowsBurst(t *testing.T) {
|
||||||
|
tb := NewTokenBucket(1.0, 5) // 1/sec, burst 5
|
||||||
|
|
||||||
|
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Should allow burst of 5 requests
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("request %d: expected 200, got %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6th request should be rate limited
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("request 6: expected 429, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenBucket_DifferentIPs(t *testing.T) {
|
||||||
|
tb := NewTokenBucket(1.0, 2) // 1/sec, burst 2
|
||||||
|
|
||||||
|
handler := tb.LimitFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exhaust IP1's bucket
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ip1 request %d: expected 200, got %d", i+1, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP1 should now be limited
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", "1.2.3.4")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusTooManyRequests {
|
||||||
|
t.Fatalf("ip1 request 3: expected 429, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP2 should still work
|
||||||
|
req = httptest.NewRequest("GET", "/test", nil)
|
||||||
|
req.Header.Set("X-Forwarded-For", "5.6.7.8")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("ip2 request 1: expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/internal/models/note.go
Normal file
20
backend/internal/models/note.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
@@ -2,13 +2,16 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/config"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/handlers"
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/middleware"
|
||||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
// Middleware
|
// Middleware
|
||||||
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
tenantResolver := auth.NewTenantResolver(tenantSvc)
|
||||||
|
|
||||||
|
noteSvc := services.NewNoteService(db)
|
||||||
dashboardSvc := services.NewDashboardService(db)
|
dashboardSvc := services.NewDashboardService(db)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
@@ -48,6 +52,8 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
ruleH := handlers.NewDeadlineRuleHandlers(deadlineRuleSvc)
|
||||||
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
calcH := handlers.NewCalculateHandlers(calculator, deadlineRuleSvc)
|
||||||
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
dashboardH := handlers.NewDashboardHandler(dashboardSvc)
|
||||||
|
noteH := handlers.NewNoteHandler(noteSvc)
|
||||||
|
eventH := handlers.NewCaseEventHandler(db)
|
||||||
docH := handlers.NewDocumentHandler(documentSvc)
|
docH := handlers.NewDocumentHandler(documentSvc)
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
@@ -82,6 +88,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
scoped.HandleFunc("DELETE /api/parties/{partyId}", partyH.Delete)
|
||||||
|
|
||||||
// Deadlines
|
// Deadlines
|
||||||
|
scoped.HandleFunc("GET /api/deadlines/{deadlineID}", deadlineH.Get)
|
||||||
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
|
scoped.HandleFunc("GET /api/deadlines", deadlineH.ListAll)
|
||||||
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
|
scoped.HandleFunc("GET /api/cases/{caseID}/deadlines", deadlineH.ListForCase)
|
||||||
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
|
scoped.HandleFunc("POST /api/cases/{caseID}/deadlines", deadlineH.Create)
|
||||||
@@ -98,11 +105,21 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
|
scoped.HandleFunc("POST /api/deadlines/calculate", calcH.Calculate)
|
||||||
|
|
||||||
// Appointments
|
// Appointments
|
||||||
|
scoped.HandleFunc("GET /api/appointments/{id}", apptH.Get)
|
||||||
scoped.HandleFunc("GET /api/appointments", apptH.List)
|
scoped.HandleFunc("GET /api/appointments", apptH.List)
|
||||||
scoped.HandleFunc("POST /api/appointments", apptH.Create)
|
scoped.HandleFunc("POST /api/appointments", apptH.Create)
|
||||||
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
scoped.HandleFunc("PUT /api/appointments/{id}", apptH.Update)
|
||||||
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
scoped.HandleFunc("DELETE /api/appointments/{id}", apptH.Delete)
|
||||||
|
|
||||||
|
// Case events
|
||||||
|
scoped.HandleFunc("GET /api/case-events/{id}", eventH.Get)
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
scoped.HandleFunc("GET /api/notes", noteH.List)
|
||||||
|
scoped.HandleFunc("POST /api/notes", noteH.Create)
|
||||||
|
scoped.HandleFunc("PUT /api/notes/{id}", noteH.Update)
|
||||||
|
scoped.HandleFunc("DELETE /api/notes/{id}", noteH.Delete)
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
scoped.HandleFunc("GET /api/dashboard", dashboardH.Get)
|
||||||
|
|
||||||
@@ -113,10 +130,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
|
scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta)
|
||||||
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
|
scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete)
|
||||||
|
|
||||||
// AI endpoints
|
// AI endpoints (rate limited: 5 req/min burst 10 per IP)
|
||||||
if aiH != nil {
|
if aiH != nil {
|
||||||
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiH.ExtractDeadlines)
|
aiLimiter := middleware.NewTokenBucket(5.0/60.0, 10)
|
||||||
scoped.HandleFunc("POST /api/ai/summarize-case", aiH.SummarizeCase)
|
scoped.HandleFunc("POST /api/ai/extract-deadlines", aiLimiter.LimitFunc(aiH.ExtractDeadlines))
|
||||||
|
scoped.HandleFunc("POST /api/ai/summarize-case", aiLimiter.LimitFunc(aiH.SummarizeCase))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CalDAV sync endpoints
|
// CalDAV sync endpoints
|
||||||
@@ -131,7 +149,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se
|
|||||||
|
|
||||||
mux.Handle("/api/", authMW.RequireAuth(api))
|
mux.Handle("/api/", authMW.RequireAuth(api))
|
||||||
|
|
||||||
return mux
|
return requestLogger(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
||||||
@@ -146,3 +164,34 @@ func handleHealth(db *sqlx.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type statusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *statusWriter) WriteHeader(code int) {
|
||||||
|
w.status = code
|
||||||
|
w.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLogger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Skip health checks to reduce noise
|
||||||
|
if r.URL.Path == "/health" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(sw, r)
|
||||||
|
|
||||||
|
slog.Info("request",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", sw.status,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -83,14 +83,14 @@ func (s *CalDAVService) Start() {
|
|||||||
s.wg.Go(func() {
|
s.wg.Go(func() {
|
||||||
s.backgroundLoop()
|
s.backgroundLoop()
|
||||||
})
|
})
|
||||||
log.Println("CalDAV sync service started")
|
slog.Info("CalDAV sync service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the background sync.
|
// Stop gracefully stops the background sync.
|
||||||
func (s *CalDAVService) Stop() {
|
func (s *CalDAVService) Stop() {
|
||||||
close(s.stopCh)
|
close(s.stopCh)
|
||||||
s.wg.Wait()
|
s.wg.Wait()
|
||||||
log.Println("CalDAV sync service stopped")
|
slog.Info("CalDAV sync service stopped")
|
||||||
}
|
}
|
||||||
|
|
||||||
// backgroundLoop polls tenants at their configured interval.
|
// backgroundLoop polls tenants at their configured interval.
|
||||||
@@ -113,7 +113,7 @@ func (s *CalDAVService) backgroundLoop() {
|
|||||||
func (s *CalDAVService) syncAllTenants() {
|
func (s *CalDAVService) syncAllTenants() {
|
||||||
configs, err := s.loadAllTenantConfigs()
|
configs, err := s.loadAllTenantConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("CalDAV: failed to load tenant configs: %v", err)
|
slog.Error("CalDAV: failed to load tenant configs", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ func (s *CalDAVService) syncAllTenants() {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if _, err := s.SyncTenant(ctx, tid, c); err != nil {
|
if _, err := s.SyncTenant(ctx, tid, c); err != nil {
|
||||||
log.Printf("CalDAV: sync failed for tenant %s: %v", tid, err)
|
slog.Error("CalDAV: sync failed", "tenant_id", tid, "error", err)
|
||||||
}
|
}
|
||||||
}(tenantID, cfg)
|
}(tenantID, cfg)
|
||||||
}
|
}
|
||||||
@@ -649,7 +649,7 @@ func (s *CalDAVService) logConflictEvent(ctx context.Context, tenantID, caseID u
|
|||||||
VALUES ($1, $2, $3, 'caldav_conflict', $4, $5, $6, NOW(), NOW())`,
|
VALUES ($1, $2, $3, 'caldav_conflict', $4, $5, $6, NOW(), NOW())`,
|
||||||
uuid.New(), tenantID, caseID, "CalDAV sync conflict", msg, metadata)
|
uuid.New(), tenantID, caseID, "CalDAV sync conflict", msg, metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("CalDAV: failed to log conflict event: %v", err)
|
slog.Error("CalDAV: failed to log conflict event", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type UpcomingDeadline struct {
|
|||||||
ID uuid.UUID `json:"id" db:"id"`
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
Title string `json:"title" db:"title"`
|
Title string `json:"title" db:"title"`
|
||||||
DueDate string `json:"due_date" db:"due_date"`
|
DueDate string `json:"due_date" db:"due_date"`
|
||||||
|
CaseID uuid.UUID `json:"case_id" db:"case_id"`
|
||||||
CaseNumber string `json:"case_number" db:"case_number"`
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
CaseTitle string `json:"case_title" db:"case_title"`
|
CaseTitle string `json:"case_title" db:"case_title"`
|
||||||
Status string `json:"status" db:"status"`
|
Status string `json:"status" db:"status"`
|
||||||
@@ -56,8 +57,10 @@ type UpcomingAppointment struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecentActivity struct {
|
type RecentActivity struct {
|
||||||
|
ID uuid.UUID `json:"id" db:"id"`
|
||||||
EventType *string `json:"event_type" db:"event_type"`
|
EventType *string `json:"event_type" db:"event_type"`
|
||||||
Title string `json:"title" db:"title"`
|
Title string `json:"title" db:"title"`
|
||||||
|
CaseID uuid.UUID `json:"case_id" db:"case_id"`
|
||||||
CaseNumber string `json:"case_number" db:"case_number"`
|
CaseNumber string `json:"case_number" db:"case_number"`
|
||||||
EventDate *time.Time `json:"event_date" db:"event_date"`
|
EventDate *time.Time `json:"event_date" db:"event_date"`
|
||||||
}
|
}
|
||||||
@@ -109,7 +112,7 @@ func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*Dashbo
|
|||||||
|
|
||||||
// Upcoming deadlines (next 7 days)
|
// Upcoming deadlines (next 7 days)
|
||||||
deadlineQuery := `
|
deadlineQuery := `
|
||||||
SELECT d.id, d.title, d.due_date, c.case_number, c.title AS case_title, d.status
|
SELECT d.id, d.title, d.due_date, d.case_id, c.case_number, c.title AS case_title, d.status
|
||||||
FROM deadlines d
|
FROM deadlines d
|
||||||
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
|
JOIN cases c ON c.id = d.case_id AND c.tenant_id = d.tenant_id
|
||||||
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
|
WHERE d.tenant_id = $1 AND d.status = 'pending' AND d.due_date >= $2 AND d.due_date <= $3
|
||||||
@@ -135,7 +138,7 @@ func (s *DashboardService) Get(ctx context.Context, tenantID uuid.UUID) (*Dashbo
|
|||||||
|
|
||||||
// Recent activity (last 10 case events)
|
// Recent activity (last 10 case events)
|
||||||
activityQuery := `
|
activityQuery := `
|
||||||
SELECT ce.event_type, ce.title, c.case_number, ce.event_date
|
SELECT ce.id, ce.event_type, ce.title, ce.case_id, c.case_number, ce.event_date
|
||||||
FROM case_events ce
|
FROM case_events ce
|
||||||
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
|
JOIN cases c ON c.id = ce.case_id AND c.tenant_id = ce.tenant_id
|
||||||
WHERE ce.tenant_id = $1
|
WHERE ce.tenant_id = $1
|
||||||
|
|||||||
120
backend/internal/services/note_service.go
Normal file
120
backend/internal/services/note_service.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
|
||||||
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NoteService struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNoteService(db *sqlx.DB) *NoteService {
|
||||||
|
return &NoteService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListByParent returns all notes for a given parent entity, scoped to tenant.
|
||||||
|
func (s *NoteService) ListByParent(ctx context.Context, tenantID uuid.UUID, parentType string, parentID uuid.UUID) ([]models.Note, error) {
|
||||||
|
col, err := parentColumn(parentType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT id, tenant_id, case_id, deadline_id, appointment_id, case_event_id,
|
||||||
|
content, created_by, created_at, updated_at
|
||||||
|
FROM notes
|
||||||
|
WHERE tenant_id = $1 AND %s = $2
|
||||||
|
ORDER BY created_at DESC`, col)
|
||||||
|
|
||||||
|
var notes []models.Note
|
||||||
|
if err := s.db.SelectContext(ctx, ¬es, query, tenantID, parentID); err != nil {
|
||||||
|
return nil, fmt.Errorf("listing notes by %s: %w", parentType, err)
|
||||||
|
}
|
||||||
|
if notes == nil {
|
||||||
|
notes = []models.Note{}
|
||||||
|
}
|
||||||
|
return notes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateNoteInput struct {
|
||||||
|
CaseID *uuid.UUID `json:"case_id,omitempty"`
|
||||||
|
DeadlineID *uuid.UUID `json:"deadline_id,omitempty"`
|
||||||
|
AppointmentID *uuid.UUID `json:"appointment_id,omitempty"`
|
||||||
|
CaseEventID *uuid.UUID `json:"case_event_id,omitempty"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new note.
|
||||||
|
func (s *NoteService) Create(ctx context.Context, tenantID uuid.UUID, createdBy *uuid.UUID, input CreateNoteInput) (*models.Note, error) {
|
||||||
|
id := uuid.New()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
query := `INSERT INTO notes (id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||||
|
RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at`
|
||||||
|
|
||||||
|
var n models.Note
|
||||||
|
err := s.db.GetContext(ctx, &n, query,
|
||||||
|
id, tenantID, input.CaseID, input.DeadlineID, input.AppointmentID, input.CaseEventID,
|
||||||
|
input.Content, createdBy, now)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating note: %w", err)
|
||||||
|
}
|
||||||
|
return &n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modifies a note's content.
|
||||||
|
func (s *NoteService) Update(ctx context.Context, tenantID, noteID uuid.UUID, content string) (*models.Note, error) {
|
||||||
|
query := `UPDATE notes SET content = $1, updated_at = $2
|
||||||
|
WHERE id = $3 AND tenant_id = $4
|
||||||
|
RETURNING id, tenant_id, case_id, deadline_id, appointment_id, case_event_id, content, created_by, created_at, updated_at`
|
||||||
|
|
||||||
|
var n models.Note
|
||||||
|
err := s.db.GetContext(ctx, &n, query, content, time.Now().UTC(), noteID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("updating note: %w", err)
|
||||||
|
}
|
||||||
|
return &n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a note.
|
||||||
|
func (s *NoteService) Delete(ctx context.Context, tenantID, noteID uuid.UUID) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, "DELETE FROM notes WHERE id = $1 AND tenant_id = $2", noteID, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleting note: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking delete result: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return fmt.Errorf("note not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parentColumn(parentType string) (string, error) {
|
||||||
|
switch parentType {
|
||||||
|
case "case":
|
||||||
|
return "case_id", nil
|
||||||
|
case "deadline":
|
||||||
|
return "deadline_id", nil
|
||||||
|
case "appointment":
|
||||||
|
return "appointment_id", nil
|
||||||
|
case "case_event":
|
||||||
|
return "case_event_id", nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid parent type: %s", parentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
167
backend/seed/demo_data.sql
Normal file
167
backend/seed/demo_data.sql
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
-- KanzlAI Demo Data
|
||||||
|
-- Creates 1 test tenant, 5 cases with deadlines and appointments
|
||||||
|
-- Run with: psql $DATABASE_URL -f demo_data.sql
|
||||||
|
|
||||||
|
SET search_path TO kanzlai, public;
|
||||||
|
|
||||||
|
-- Demo tenant
|
||||||
|
INSERT INTO tenants (id, name, slug, settings) VALUES
|
||||||
|
('a0000000-0000-0000-0000-000000000001', 'Kanzlei Siebels & Partner', 'siebels-partner', '{}')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Link both users to the demo tenant
|
||||||
|
INSERT INTO user_tenants (user_id, tenant_id, role) VALUES
|
||||||
|
('1da9374d-a8a6-49fc-a2ec-5ddfa91d522d', 'a0000000-0000-0000-0000-000000000001', 'owner'),
|
||||||
|
('ac6c9501-3757-4a6d-8b97-2cff4288382b', 'a0000000-0000-0000-0000-000000000001', 'member')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case 1: Patentverletzung (patent infringement) — active
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000001',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'2026/001', 'TechCorp GmbH ./. InnovatAG — Patentverletzung EP 1234567',
|
||||||
|
'patent', 'UPC München (Lokalkammer)', 'UPC_CFI-123/2026',
|
||||||
|
'active');
|
||||||
|
|
||||||
|
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'TechCorp GmbH', 'claimant', 'RA Dr. Siebels'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'InnovatAG', 'defendant', 'RA Müller');
|
||||||
|
|
||||||
|
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'Klageerwiderung einreichen', CURRENT_DATE + INTERVAL '3 days', CURRENT_DATE + INTERVAL '1 day', 'pending', 'manual'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'Beweisangebote nachreichen', CURRENT_DATE + INTERVAL '14 days', CURRENT_DATE + INTERVAL '10 days', 'pending', 'manual'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'Schriftsatz Anspruch 3', CURRENT_DATE - INTERVAL '2 days', CURRENT_DATE - INTERVAL '5 days', 'pending', 'manual');
|
||||||
|
|
||||||
|
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'Mündliche Verhandlung', CURRENT_DATE + INTERVAL '21 days' + TIME '10:00', CURRENT_DATE + INTERVAL '21 days' + TIME '12:00',
|
||||||
|
'UPC München, Saal 4', 'hearing');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case 2: Markenrecht (trademark) — active
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000002',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'2026/002', 'BrandHouse ./. CopyShop UG — Markenverletzung DE 30201234',
|
||||||
|
'trademark', 'LG Hamburg', '315 O 78/26',
|
||||||
|
'active');
|
||||||
|
|
||||||
|
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'BrandHouse SE', 'claimant', 'RA Dr. Siebels'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'CopyShop UG', 'defendant', 'RA Weber');
|
||||||
|
|
||||||
|
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'Antrag einstweilige Verfügung', CURRENT_DATE + INTERVAL '5 days', CURRENT_DATE + INTERVAL '2 days', 'pending', 'manual'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'Abmahnung Fristablauf', CURRENT_DATE + INTERVAL '30 days', CURRENT_DATE + INTERVAL '25 days', 'pending', 'manual');
|
||||||
|
|
||||||
|
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'Mandantenbesprechung BrandHouse', CURRENT_DATE + INTERVAL '2 days' + TIME '14:00', CURRENT_DATE + INTERVAL '2 days' + TIME '15:30',
|
||||||
|
'Kanzlei, Besprechungsraum 1', 'consultation');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case 3: Arbeitsgericht (labor law) — active
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000003',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'2026/003', 'Schmidt ./. AutoWerk Bayern GmbH — Kündigungsschutz',
|
||||||
|
'labor', 'ArbG München', '12 Ca 456/26',
|
||||||
|
'active');
|
||||||
|
|
||||||
|
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'Klaus Schmidt', 'claimant', 'RA Dr. Siebels'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'AutoWerk Bayern GmbH', 'defendant', 'RA Fischer');
|
||||||
|
|
||||||
|
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'Kündigungsschutzklage einreichen (3-Wochen-Frist)', CURRENT_DATE + INTERVAL '7 days', CURRENT_DATE + INTERVAL '4 days', 'pending', 'manual'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'Stellungnahme Arbeitgeber', CURRENT_DATE + INTERVAL '28 days', CURRENT_DATE + INTERVAL '21 days', 'pending', 'manual');
|
||||||
|
|
||||||
|
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'Güteverhandlung', CURRENT_DATE + INTERVAL '35 days' + TIME '09:00', CURRENT_DATE + INTERVAL '35 days' + TIME '10:00',
|
||||||
|
'ArbG München, Saal 12', 'hearing');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case 4: Mietrecht (tenancy) — active
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000004',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'2026/004', 'Hausverwaltung Zentral ./. Meier — Mietrückstand',
|
||||||
|
'civil', 'AG München', '432 C 1234/26',
|
||||||
|
'active');
|
||||||
|
|
||||||
|
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'Hausverwaltung Zentral GmbH', 'claimant', 'RA Dr. Siebels'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'Thomas Meier', 'defendant', NULL);
|
||||||
|
|
||||||
|
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'Mahnbescheid beantragen', CURRENT_DATE + INTERVAL '10 days', CURRENT_DATE + INTERVAL '7 days', 'pending', 'manual'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'Räumungsfrist prüfen', CURRENT_DATE + INTERVAL '60 days', CURRENT_DATE + INTERVAL '50 days', 'pending', 'manual');
|
||||||
|
|
||||||
|
INSERT INTO appointments (id, tenant_id, case_id, title, start_at, end_at, location, appointment_type) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'Besprechung Hausverwaltung', CURRENT_DATE + INTERVAL '4 days' + TIME '11:00', CURRENT_DATE + INTERVAL '4 days' + TIME '12:00',
|
||||||
|
'Kanzlei, Besprechungsraum 2', 'meeting');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case 5: Erbrecht (inheritance) — closed
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO cases (id, tenant_id, case_number, title, case_type, court, court_ref, status) VALUES
|
||||||
|
('c0000000-0000-0000-0000-000000000005',
|
||||||
|
'a0000000-0000-0000-0000-000000000001',
|
||||||
|
'2025/042', 'Nachlass Wagner — Erbauseinandersetzung',
|
||||||
|
'civil', 'AG Starnberg', '3 VI 891/25',
|
||||||
|
'closed');
|
||||||
|
|
||||||
|
INSERT INTO parties (id, tenant_id, case_id, name, role, representative) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'Maria Wagner', 'claimant', 'RA Dr. Siebels'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'Peter Wagner', 'defendant', 'RA Braun');
|
||||||
|
|
||||||
|
INSERT INTO deadlines (id, tenant_id, case_id, title, due_date, warning_date, status, source, completed_at) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'Erbscheinsantrag einreichen', CURRENT_DATE - INTERVAL '30 days', CURRENT_DATE - INTERVAL '37 days', 'completed', 'manual', CURRENT_DATE - INTERVAL '32 days');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Case events for realistic activity feed
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO case_events (id, tenant_id, case_id, event_type, title, description, created_at, updated_at) VALUES
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'case_created', 'Akte angelegt', 'Patentverletzungsklage TechCorp ./. InnovatAG eröffnet', NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'party_added', 'Partei hinzugefügt', 'TechCorp GmbH als Kläger eingetragen', NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000002',
|
||||||
|
'case_created', 'Akte angelegt', 'Markenrechtsstreit BrandHouse ./. CopyShop eröffnet', NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000003',
|
||||||
|
'case_created', 'Akte angelegt', 'Kündigungsschutzklage Schmidt eröffnet', NOW() - INTERVAL '5 days', NOW() - INTERVAL '5 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000004',
|
||||||
|
'case_created', 'Akte angelegt', 'Mietrückstand Hausverwaltung ./. Meier eröffnet', NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001',
|
||||||
|
'status_changed', 'Fristablauf überschritten', 'Schriftsatz Anspruch 3 ist überfällig', NOW() - INTERVAL '1 day', NOW() - INTERVAL '1 day'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'case_created', 'Akte angelegt', 'Erbauseinandersetzung Wagner eröffnet', NOW() - INTERVAL '60 days', NOW() - INTERVAL '60 days'),
|
||||||
|
(gen_random_uuid(), 'a0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000005',
|
||||||
|
'status_changed', 'Akte geschlossen', 'Erbscheinsverfahren abgeschlossen', NOW() - INTERVAL '20 days', NOW() - INTERVAL '20 days');
|
||||||
@@ -6,6 +6,12 @@ services:
|
|||||||
- "8080"
|
- "8080"
|
||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- SUPABASE_URL=${SUPABASE_URL}
|
||||||
|
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||||
|
- SUPABASE_SERVICE_KEY=${SUPABASE_SERVICE_KEY}
|
||||||
|
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -16,6 +22,9 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
|
||||||
expose:
|
expose:
|
||||||
- "3000"
|
- "3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -23,6 +32,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
- API_URL=http://backend:8080
|
- API_URL=http://backend:8080
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:3000').then(r=>{if(!r.ok)throw r.status;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
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 --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV API_URL=http://backend:8080
|
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 mkdir -p public
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
|
|||||||
@@ -19,25 +19,97 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@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/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.14",
|
"eslint-config-next": "15.5.14",
|
||||||
|
"jsdom": "24.1.3",
|
||||||
|
"msw": "^2.12.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
"vitest": "2.1.8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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/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/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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||||
|
|
||||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="],
|
"@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=="],
|
"@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=="],
|
"@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@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/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=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
|
"@typescript-eslint/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=="],
|
"@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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||||
|
|
||||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"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=="],
|
"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=="],
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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-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": ["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-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-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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"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=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
||||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||||
@@ -704,18 +980,30 @@
|
|||||||
|
|
||||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
"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=="],
|
"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=="],
|
"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": ["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-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||||
|
|
||||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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.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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"@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 () => [
|
rewrites: async () => [
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
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",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/ssr": "^0.9.0",
|
"@supabase/ssr": "^0.9.0",
|
||||||
@@ -21,14 +23,20 @@
|
|||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.14",
|
"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({
|
const { data: casesData } = useQuery({
|
||||||
queryKey: ["cases"],
|
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) {
|
async function handleExtract(file: File | null, text: string) {
|
||||||
setIsExtracting(true);
|
setIsExtracting(true);
|
||||||
@@ -40,12 +40,12 @@ export default function AIExtractPage() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
response = await api.postFormData<ExtractionResponse>(
|
response = await api.postFormData<ExtractionResponse>(
|
||||||
"/api/ai/extract-deadlines",
|
"/ai/extract-deadlines",
|
||||||
formData,
|
formData,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
response = await api.post<ExtractionResponse>(
|
response = await api.post<ExtractionResponse>(
|
||||||
"/api/ai/extract-deadlines",
|
"/ai/extract-deadlines",
|
||||||
{ text },
|
{ text },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export default function AIExtractPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const promises = deadlines.map((d) =>
|
const promises = deadlines.map((d) =>
|
||||||
api.post(`/api/cases/${selectedCaseId}/deadlines`, {
|
api.post(`/cases/${selectedCaseId}/deadlines`, {
|
||||||
title: d.title,
|
title: d.title,
|
||||||
due_date: d.due_date ?? "",
|
due_date: d.due_date ?? "",
|
||||||
source: "ai_extraction",
|
source: "ai_extraction",
|
||||||
|
|||||||
35
frontend/src/app/(app)/cases/[id]/dokumente/page.tsx
Normal file
35
frontend/src/app/(app)/cases/[id]/dokumente/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Document } from "@/lib/types";
|
||||||
|
import { DocumentList } from "@/components/documents/DocumentList";
|
||||||
|
import { DocumentUpload } from "@/components/documents/DocumentUpload";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export default function DokumentePage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["case-documents", id],
|
||||||
|
queryFn: () => api.get<Document[]>(`/cases/${id}/documents`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documents = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DocumentUpload caseId={id} />
|
||||||
|
<DocumentList documents={documents} caseId={id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
frontend/src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx
Normal file
230
frontend/src/app/(app)/cases/[id]/ereignisse/[eventId]/page.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { CaseEvent, Case } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { NotesList } from "@/components/notes/NotesList";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
FileText,
|
||||||
|
Scale,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Calendar,
|
||||||
|
MessageSquare,
|
||||||
|
Gavel,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const EVENT_TYPE_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; icon: typeof Info; color: string }
|
||||||
|
> = {
|
||||||
|
status_changed: {
|
||||||
|
label: "Statusaenderung",
|
||||||
|
icon: ArrowRightLeft,
|
||||||
|
color: "bg-blue-50 text-blue-700",
|
||||||
|
},
|
||||||
|
deadline_created: {
|
||||||
|
label: "Frist erstellt",
|
||||||
|
icon: Calendar,
|
||||||
|
color: "bg-amber-50 text-amber-700",
|
||||||
|
},
|
||||||
|
deadline_completed: {
|
||||||
|
label: "Frist erledigt",
|
||||||
|
icon: Calendar,
|
||||||
|
color: "bg-emerald-50 text-emerald-700",
|
||||||
|
},
|
||||||
|
document_uploaded: {
|
||||||
|
label: "Dokument hochgeladen",
|
||||||
|
icon: FileText,
|
||||||
|
color: "bg-violet-50 text-violet-700",
|
||||||
|
},
|
||||||
|
hearing_scheduled: {
|
||||||
|
label: "Verhandlung angesetzt",
|
||||||
|
icon: Gavel,
|
||||||
|
color: "bg-rose-50 text-rose-700",
|
||||||
|
},
|
||||||
|
note_added: {
|
||||||
|
label: "Notiz hinzugefuegt",
|
||||||
|
icon: MessageSquare,
|
||||||
|
color: "bg-neutral-100 text-neutral-700",
|
||||||
|
},
|
||||||
|
case_created: {
|
||||||
|
label: "Akte erstellt",
|
||||||
|
icon: Scale,
|
||||||
|
color: "bg-emerald-50 text-emerald-700",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_EVENT_CONFIG = {
|
||||||
|
label: "Ereignis",
|
||||||
|
icon: Info,
|
||||||
|
color: "bg-neutral-100 text-neutral-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-32 rounded-lg" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CaseEventDetailPage() {
|
||||||
|
const { id: caseId, eventId } = useParams<{
|
||||||
|
id: string;
|
||||||
|
eventId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { data: caseData } = useQuery({
|
||||||
|
queryKey: ["case", caseId],
|
||||||
|
queryFn: () => api.get<Case>(`/cases/${caseId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: event,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["case-event", eventId],
|
||||||
|
queryFn: () => api.get<CaseEvent>(`/case-events/${eventId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error || !event) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
Ereignis nicht gefunden
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Das Ereignis existiert nicht oder Sie haben keine Berechtigung.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${caseId}`}
|
||||||
|
className="mt-4 inline-block text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
Zurueck zur Akte
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfig =
|
||||||
|
EVENT_TYPE_CONFIG[event.event_type ?? ""] ?? DEFAULT_EVENT_CONFIG;
|
||||||
|
const TypeIcon = typeConfig.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Akten", href: "/cases" },
|
||||||
|
{
|
||||||
|
label: caseData?.case_number
|
||||||
|
? `Az. ${caseData.case_number}`
|
||||||
|
: "Akte",
|
||||||
|
href: `/cases/${caseId}`,
|
||||||
|
},
|
||||||
|
{ label: "Verlauf", href: `/cases/${caseId}` },
|
||||||
|
{ label: event.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className={`rounded-lg p-2 ${typeConfig.color}`}>
|
||||||
|
<TypeIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
{event.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{event.event_date
|
||||||
|
? format(parseISO(event.event_date), "d. MMMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})
|
||||||
|
: format(parseISO(event.created_at), "d. MMMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{event.description && (
|
||||||
|
<div className="mt-4 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Beschreibung
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm text-neutral-700">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Metadaten
|
||||||
|
</p>
|
||||||
|
<dl className="mt-2 space-y-1.5">
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<dt className="text-neutral-500">Typ:</dt>
|
||||||
|
<dd>
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${typeConfig.color}`}
|
||||||
|
>
|
||||||
|
{typeConfig.label}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{event.created_by && (
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<dt className="text-neutral-500">Erstellt von:</dt>
|
||||||
|
<dd className="text-neutral-900">{event.created_by}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<dt className="text-neutral-500">Erstellt am:</dt>
|
||||||
|
<dd className="text-neutral-900">
|
||||||
|
{format(parseISO(event.created_at), "d. MMMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{event.metadata &&
|
||||||
|
Object.keys(event.metadata).length > 0 &&
|
||||||
|
Object.entries(event.metadata).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex gap-2 text-sm">
|
||||||
|
<dt className="text-neutral-500">{key}:</dt>
|
||||||
|
<dd className="text-neutral-900">{String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<NotesList parentType="case_event" parentId={eventId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/app/(app)/cases/[id]/fristen/page.tsx
Normal file
86
frontend/src/app/(app)/cases/[id]/fristen/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Deadline } from "@/lib/types";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import { Clock, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const DEADLINE_STATUS: Record<string, string> = {
|
||||||
|
pending: "bg-amber-50 text-amber-700",
|
||||||
|
completed: "bg-emerald-50 text-emerald-700",
|
||||||
|
overdue: "bg-red-50 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEADLINE_STATUS_LABEL: Record<string, string> = {
|
||||||
|
pending: "Offen",
|
||||||
|
completed: "Erledigt",
|
||||||
|
overdue: "Ueberfaellig",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FristenPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["case-deadlines", id],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ deadlines: Deadline[]; total: number }>(
|
||||||
|
`/deadlines?case_id=${id}`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlines = Array.isArray(data?.deadlines) ? data.deadlines : [];
|
||||||
|
|
||||||
|
if (deadlines.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<div className="rounded-xl bg-neutral-100 p-3">
|
||||||
|
<Clock className="h-5 w-5 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
|
Keine Fristen vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deadlines.map((d) => (
|
||||||
|
<div
|
||||||
|
key={d.id}
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
|
||||||
|
{d.description && (
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
|
{d.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs font-medium ${DEADLINE_STATUS[d.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap text-sm text-neutral-500">
|
||||||
|
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend/src/app/(app)/cases/[id]/layout.tsx
Normal file
226
frontend/src/app/(app)/cases/[id]/layout.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams, usePathname } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Case } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Users,
|
||||||
|
StickyNote,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
|
||||||
|
interface CaseDetail extends Case {
|
||||||
|
parties: unknown[];
|
||||||
|
deadlines_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<string, string> = {
|
||||||
|
active: "bg-emerald-50 text-emerald-700",
|
||||||
|
pending: "bg-amber-50 text-amber-700",
|
||||||
|
closed: "bg-neutral-100 text-neutral-600",
|
||||||
|
archived: "bg-neutral-100 text-neutral-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
active: "Aktiv",
|
||||||
|
pending: "Anhaengig",
|
||||||
|
closed: "Geschlossen",
|
||||||
|
archived: "Archiviert",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ segment: "verlauf", label: "Verlauf", icon: Activity },
|
||||||
|
{ segment: "fristen", label: "Fristen", icon: Clock },
|
||||||
|
{ segment: "dokumente", label: "Dokumente", icon: FileText },
|
||||||
|
{ segment: "parteien", label: "Parteien", icon: Users },
|
||||||
|
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<string, string> = {
|
||||||
|
verlauf: "Verlauf",
|
||||||
|
fristen: "Fristen",
|
||||||
|
dokumente: "Dokumente",
|
||||||
|
parteien: "Parteien",
|
||||||
|
notizen: "Notizen",
|
||||||
|
};
|
||||||
|
|
||||||
|
function CaseDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<div className="mt-4 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex gap-4 border-b border-neutral-200 pb-2.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-4 w-20" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 space-y-3">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-14 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CaseDetailLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: caseDetail,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["case", id],
|
||||||
|
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine active tab from pathname
|
||||||
|
const segments = pathname.split("/");
|
||||||
|
const activeSegment = segments[segments.length - 1] || "verlauf";
|
||||||
|
const activeTabLabel = TAB_LABELS[activeSegment];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <CaseDetailSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !caseDetail) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
Akte nicht gefunden
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Die Akte existiert nicht oder Sie haben keine Berechtigung.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/cases"
|
||||||
|
className="mt-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
|
Zurueck zu Akten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Akten", href: "/cases" },
|
||||||
|
{ label: caseDetail.case_number, href: `/cases/${id}/verlauf` },
|
||||||
|
...(activeTabLabel ? [{ label: activeTabLabel }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const partiesCount = Array.isArray(caseDetail.parties)
|
||||||
|
? caseDetail.parties.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
{caseDetail.title}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[caseDetail.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-neutral-500">
|
||||||
|
<span>Az. {caseDetail.case_number}</span>
|
||||||
|
{caseDetail.case_type && <span>{caseDetail.case_type}</span>}
|
||||||
|
{caseDetail.court && <span>{caseDetail.court}</span>}
|
||||||
|
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-neutral-400">
|
||||||
|
<p>
|
||||||
|
Erstellt:{" "}
|
||||||
|
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Aktualisiert:{" "}
|
||||||
|
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{caseDetail.ai_summary && (
|
||||||
|
<div className="mt-4 rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
||||||
|
{caseDetail.ai_summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 border-b border-neutral-200">
|
||||||
|
<nav className="-mb-px flex gap-1 overflow-x-auto sm:gap-4">
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const isActive = activeSegment === tab.segment;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.segment}
|
||||||
|
href={`/cases/${id}/${tab.segment}`}
|
||||||
|
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "border-neutral-900 text-neutral-900"
|
||||||
|
: "border-transparent text-neutral-400 hover:text-neutral-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon className="h-4 w-4" />
|
||||||
|
{tab.label}
|
||||||
|
{tab.segment === "fristen" &&
|
||||||
|
caseDetail.deadlines_count > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
|
||||||
|
{caseDetail.deadlines_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab.segment === "parteien" && partiesCount > 0 && (
|
||||||
|
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
|
||||||
|
{partiesCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/app/(app)/cases/[id]/notizen/page.tsx
Normal file
10
frontend/src/app/(app)/cases/[id]/notizen/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { NotesList } from "@/components/notes/NotesList";
|
||||||
|
|
||||||
|
export default function NotizenPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
return <NotesList parentType="case" parentId={id} />;
|
||||||
|
}
|
||||||
@@ -1,341 +1,10 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
export default async function CaseDetailPage({
|
||||||
import { useParams } from "next/navigation";
|
params,
|
||||||
import { api } from "@/lib/api";
|
}: {
|
||||||
import type { Case, CaseEvent, Party, Deadline, Document } from "@/lib/types";
|
params: Promise<{ id: string }>;
|
||||||
import { CaseTimeline } from "@/components/cases/CaseTimeline";
|
}) {
|
||||||
import { PartyList } from "@/components/cases/PartyList";
|
const { id } = await params;
|
||||||
import {
|
redirect(`/cases/${id}/verlauf`);
|
||||||
ArrowLeft,
|
|
||||||
Clock,
|
|
||||||
FileText,
|
|
||||||
Users,
|
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { de } from "date-fns/locale";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Skeleton } from "@/components/ui/Skeleton";
|
|
||||||
|
|
||||||
interface CaseDetail extends Case {
|
|
||||||
parties: Party[];
|
|
||||||
recent_events: CaseEvent[];
|
|
||||||
deadlines_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
|
||||||
active: "bg-emerald-50 text-emerald-700",
|
|
||||||
pending: "bg-amber-50 text-amber-700",
|
|
||||||
closed: "bg-neutral-100 text-neutral-600",
|
|
||||||
archived: "bg-neutral-100 text-neutral-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
|
||||||
active: "Aktiv",
|
|
||||||
pending: "Anhängig",
|
|
||||||
closed: "Geschlossen",
|
|
||||||
archived: "Archiviert",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ key: "timeline", label: "Verlauf", icon: Activity },
|
|
||||||
{ key: "deadlines", label: "Fristen", icon: Clock },
|
|
||||||
{ key: "documents", label: "Dokumente", icon: FileText },
|
|
||||||
{ key: "parties", label: "Parteien", icon: Users },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
type TabKey = (typeof TABS)[number]["key"];
|
|
||||||
|
|
||||||
function CaseDetailSkeleton() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-4 w-28" />
|
|
||||||
<div className="mt-4 flex items-start justify-between">
|
|
||||||
<div>
|
|
||||||
<Skeleton className="h-6 w-48" />
|
|
||||||
<Skeleton className="mt-2 h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex gap-4 border-b border-neutral-200 pb-2.5">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-4 w-20" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 space-y-3">
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<Skeleton key={i} className="h-14 rounded-md" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CaseDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("timeline");
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: caseDetail,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["case", id],
|
|
||||||
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: deadlinesData } = useQuery({
|
|
||||||
queryKey: ["case-deadlines", id],
|
|
||||||
queryFn: () =>
|
|
||||||
api.get<{ deadlines: Deadline[]; total: number }>(
|
|
||||||
`/deadlines?case_id=${id}`,
|
|
||||||
),
|
|
||||||
enabled: activeTab === "deadlines",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: documentsData } = useQuery({
|
|
||||||
queryKey: ["case-documents", id],
|
|
||||||
queryFn: () => api.get<Document[]>(`/cases/${id}/documents`),
|
|
||||||
enabled: activeTab === "documents",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <CaseDetailSkeleton />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !caseDetail) {
|
|
||||||
return (
|
|
||||||
<div className="py-12 text-center">
|
|
||||||
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
|
||||||
Akte nicht gefunden
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
|
||||||
Die Akte existiert nicht oder Sie haben keine Berechtigung.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/cases"
|
|
||||||
className="mt-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
|
||||||
Zurück zu Akten
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deadlines = deadlinesData?.deadlines ?? [];
|
|
||||||
const documents = documentsData ?? [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<Link
|
|
||||||
href="/cases"
|
|
||||||
className="mb-4 inline-flex items-center gap-1 text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5" />
|
|
||||||
Zurück zu Akten
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="text-lg font-semibold text-neutral-900">
|
|
||||||
{caseDetail.title}
|
|
||||||
</h1>
|
|
||||||
<span
|
|
||||||
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_BADGE[caseDetail.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
|
||||||
>
|
|
||||||
{STATUS_LABEL[caseDetail.status] ?? caseDetail.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-sm text-neutral-500">
|
|
||||||
<span>Az. {caseDetail.case_number}</span>
|
|
||||||
{caseDetail.case_type && <span>{caseDetail.case_type}</span>}
|
|
||||||
{caseDetail.court && <span>{caseDetail.court}</span>}
|
|
||||||
{caseDetail.court_ref && <span>({caseDetail.court_ref})</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right text-xs text-neutral-400">
|
|
||||||
<p>
|
|
||||||
Erstellt:{" "}
|
|
||||||
{format(new Date(caseDetail.created_at), "d. MMM yyyy", {
|
|
||||||
locale: de,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Aktualisiert:{" "}
|
|
||||||
{format(new Date(caseDetail.updated_at), "d. MMM yyyy", {
|
|
||||||
locale: de,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{caseDetail.ai_summary && (
|
|
||||||
<div className="mt-4 rounded-md border border-blue-100 bg-blue-50 px-4 py-3 text-sm text-blue-800">
|
|
||||||
{caseDetail.ai_summary}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 border-b border-neutral-200">
|
|
||||||
<nav className="-mb-px flex gap-1 overflow-x-auto sm:gap-4">
|
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.key}
|
|
||||||
onClick={() => setActiveTab(tab.key)}
|
|
||||||
className={`inline-flex shrink-0 items-center gap-1.5 border-b-2 px-1 pb-2.5 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.key
|
|
||||||
? "border-neutral-900 text-neutral-900"
|
|
||||||
: "border-transparent text-neutral-400 hover:text-neutral-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<tab.icon className="h-4 w-4" />
|
|
||||||
{tab.label}
|
|
||||||
{tab.key === "deadlines" && caseDetail.deadlines_count > 0 && (
|
|
||||||
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
|
|
||||||
{caseDetail.deadlines_count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{tab.key === "parties" && caseDetail.parties.length > 0 && (
|
|
||||||
<span className="ml-1 rounded-full bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500">
|
|
||||||
{caseDetail.parties.length}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
{activeTab === "timeline" && (
|
|
||||||
<CaseTimeline events={caseDetail.recent_events ?? []} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "deadlines" && (
|
|
||||||
<DeadlinesList deadlines={deadlines} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "documents" && (
|
|
||||||
<DocumentsList documents={documents} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === "parties" && (
|
|
||||||
<PartyList caseId={id} parties={caseDetail.parties ?? []} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) {
|
|
||||||
if (deadlines.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center py-8 text-center">
|
|
||||||
<div className="rounded-xl bg-neutral-100 p-3">
|
|
||||||
<Clock className="h-5 w-5 text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
|
||||||
Keine Fristen vorhanden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEADLINE_STATUS: Record<string, string> = {
|
|
||||||
pending: "bg-amber-50 text-amber-700",
|
|
||||||
completed: "bg-emerald-50 text-emerald-700",
|
|
||||||
overdue: "bg-red-50 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEADLINE_STATUS_LABEL: Record<string, string> = {
|
|
||||||
pending: "Offen",
|
|
||||||
completed: "Erledigt",
|
|
||||||
overdue: "Überfällig",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{deadlines.map((d) => (
|
|
||||||
<div
|
|
||||||
key={d.id}
|
|
||||||
className="flex flex-col gap-2 rounded-md border border-neutral-200 bg-white px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-neutral-900">{d.title}</p>
|
|
||||||
{d.description && (
|
|
||||||
<p className="mt-0.5 text-sm text-neutral-500">
|
|
||||||
{d.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${DEADLINE_STATUS[d.status] ?? "bg-neutral-100 text-neutral-500"}`}
|
|
||||||
>
|
|
||||||
{DEADLINE_STATUS_LABEL[d.status] ?? d.status}
|
|
||||||
</span>
|
|
||||||
<span className="whitespace-nowrap text-sm text-neutral-500">
|
|
||||||
{format(new Date(d.due_date), "d. MMM yyyy", { locale: de })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocumentsList({ documents }: { documents: Document[] }) {
|
|
||||||
if (documents.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center py-8 text-center">
|
|
||||||
<div className="rounded-xl bg-neutral-100 p-3">
|
|
||||||
<FileText className="h-5 w-5 text-neutral-400" />
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
|
||||||
Keine Dokumente vorhanden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{documents.map((doc) => (
|
|
||||||
<div
|
|
||||||
key={doc.id}
|
|
||||||
className="flex items-center justify-between rounded-md border border-neutral-200 bg-white px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FileText className="h-4 w-4 text-neutral-400" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
|
||||||
{doc.title}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2 text-xs text-neutral-400">
|
|
||||||
{doc.doc_type && <span>{doc.doc_type}</span>}
|
|
||||||
{doc.file_size && (
|
|
||||||
<span>{(doc.file_size / 1024).toFixed(0)} KB</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={`/api/documents/${doc.id}`}
|
|
||||||
className="text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
|
||||||
>
|
|
||||||
Herunterladen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
35
frontend/src/app/(app)/cases/[id]/parteien/page.tsx
Normal file
35
frontend/src/app/(app)/cases/[id]/parteien/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Case, Party } from "@/lib/types";
|
||||||
|
import { PartyList } from "@/components/cases/PartyList";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface CaseDetail extends Case {
|
||||||
|
parties: Party[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParteienPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: caseDetail, isLoading } = useQuery({
|
||||||
|
queryKey: ["case", id],
|
||||||
|
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parties = Array.isArray(caseDetail?.parties)
|
||||||
|
? caseDetail.parties
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return <PartyList caseId={id} parties={parties} />;
|
||||||
|
}
|
||||||
35
frontend/src/app/(app)/cases/[id]/verlauf/page.tsx
Normal file
35
frontend/src/app/(app)/cases/[id]/verlauf/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Case, CaseEvent } from "@/lib/types";
|
||||||
|
import { CaseTimeline } from "@/components/cases/CaseTimeline";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface CaseDetail extends Case {
|
||||||
|
recent_events: CaseEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerlaufPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const { data: caseDetail, isLoading } = useQuery({
|
||||||
|
queryKey: ["case", id],
|
||||||
|
queryFn: () => api.get<CaseDetail>(`/cases/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = Array.isArray(caseDetail?.recent_events)
|
||||||
|
? caseDetail.recent_events
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return <CaseTimeline events={events} />;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { api } from "@/lib/api";
|
|||||||
import type { Case } from "@/lib/types";
|
import type { Case } from "@/lib/types";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Plus, Search, FolderOpen } from "lucide-react";
|
import { Plus, Search, FolderOpen } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SkeletonTable } from "@/components/ui/Skeleton";
|
import { SkeletonTable } from "@/components/ui/Skeleton";
|
||||||
@@ -68,10 +69,16 @@ export default function CasesPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const cases = data?.cases ?? [];
|
const cases = Array.isArray(data?.cases) ? data.cases : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Akten" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-neutral-900">Akten</h1>
|
<h1 className="text-lg font-semibold text-neutral-900">Akten</h1>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { CaseOverviewGrid } from "@/components/dashboard/CaseOverviewGrid";
|
|||||||
import { UpcomingTimeline } from "@/components/dashboard/UpcomingTimeline";
|
import { UpcomingTimeline } from "@/components/dashboard/UpcomingTimeline";
|
||||||
import { AISummaryCard } from "@/components/dashboard/AISummaryCard";
|
import { AISummaryCard } from "@/components/dashboard/AISummaryCard";
|
||||||
import { QuickActions } from "@/components/dashboard/QuickActions";
|
import { QuickActions } from "@/components/dashboard/QuickActions";
|
||||||
|
import { RecentActivityList } from "@/components/dashboard/RecentActivityList";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Skeleton, SkeletonCard } from "@/components/ui/Skeleton";
|
import { Skeleton, SkeletonCard } from "@/components/ui/Skeleton";
|
||||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
@@ -71,30 +73,37 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recentActivity = Array.isArray(data.recent_activity) ? data.recent_activity : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in mx-auto max-w-6xl space-y-6">
|
<div className="animate-fade-in mx-auto max-w-6xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
<Breadcrumb items={[{ label: "Dashboard" }]} />
|
||||||
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
|
<h1 className="text-lg font-semibold text-neutral-900">Dashboard</h1>
|
||||||
<p className="mt-0.5 text-sm text-neutral-500">
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
Fristenübersicht und Kanzlei-Status
|
Fristenübersicht und Kanzlei-Status
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<UpcomingTimeline
|
<UpcomingTimeline
|
||||||
deadlines={data.upcoming_deadlines}
|
deadlines={Array.isArray(data.upcoming_deadlines) ? data.upcoming_deadlines : []}
|
||||||
appointments={data.upcoming_appointments}
|
appointments={Array.isArray(data.upcoming_appointments) ? data.upcoming_appointments : []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<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} />
|
<AISummaryCard data={data} onRefresh={() => refetch()} />
|
||||||
<QuickActions />
|
<QuickActions />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{recentActivity.length > 0 && (
|
||||||
|
<RecentActivityList activities={recentActivity} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function EinstellungenPage() {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["tenant-current", tenantId],
|
queryKey: ["tenant-current", tenantId],
|
||||||
queryFn: () => api.get<Tenant>(`/api/tenants/${tenantId}`),
|
queryFn: () => api.get<Tenant>(`/tenants/${tenantId}`),
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
250
frontend/src/app/(app)/fristen/[id]/page.tsx
Normal file
250
frontend/src/app/(app)/fristen/[id]/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Deadline } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { NotesList } from "@/components/notes/NotesList";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import { format, parseISO, formatDistanceToNow, isPast } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface DeadlineDetail extends Deadline {
|
||||||
|
case_number?: string;
|
||||||
|
case_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; bg: string; icon: typeof Clock }
|
||||||
|
> = {
|
||||||
|
pending: { label: "Offen", bg: "bg-amber-50 text-amber-700", icon: Clock },
|
||||||
|
completed: {
|
||||||
|
label: "Erledigt",
|
||||||
|
bg: "bg-emerald-50 text-emerald-700",
|
||||||
|
icon: CheckCircle2,
|
||||||
|
},
|
||||||
|
overdue: {
|
||||||
|
label: "Ueberfaellig",
|
||||||
|
bg: "bg-red-50 text-red-700",
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEffectiveStatus(d: DeadlineDetail): string {
|
||||||
|
if (d.status === "completed") return "completed";
|
||||||
|
if (isPast(parseISO(d.due_date))) return "overdue";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-32 rounded-lg" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeadlineDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: deadline,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["deadline", id],
|
||||||
|
queryFn: () => api.get<DeadlineDetail>(`/deadlines/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: () => api.patch<Deadline>(`/deadlines/${id}/complete`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["deadline", id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
toast.success("Frist als erledigt markiert");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Abschliessen der Frist"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error || !deadline) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
Frist nicht gefunden
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Die Frist existiert nicht oder Sie haben keine Berechtigung.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/fristen"
|
||||||
|
className="mt-4 inline-block text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
Zurueck zu Fristen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getEffectiveStatus(deadline);
|
||||||
|
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
|
||||||
|
const StatusIcon = config.icon;
|
||||||
|
const dueDate = parseISO(deadline.due_date);
|
||||||
|
const relativeTime = formatDistanceToNow(dueDate, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: de,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Fristen", href: "/fristen" },
|
||||||
|
{ label: deadline.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${config.bg}`}
|
||||||
|
>
|
||||||
|
<StatusIcon className="h-3 w-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
{deadline.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{deadline.description && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
{deadline.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{deadline.status !== "completed" && (
|
||||||
|
<button
|
||||||
|
onClick={() => completeMutation.mutate()}
|
||||||
|
disabled={completeMutation.isPending}
|
||||||
|
className="shrink-0 rounded-md bg-emerald-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? "Wird erledigt..." : "Erledigen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
<div className="mt-4 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
Faellig: {format(dueDate, "d. MMMM yyyy", { locale: de })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs ${status === "overdue" ? "font-medium text-red-600" : "text-neutral-500"}`}
|
||||||
|
>
|
||||||
|
({relativeTime})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{deadline.warning_date && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Warnung am:{" "}
|
||||||
|
{format(parseISO(deadline.warning_date), "d. MMMM yyyy", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{deadline.original_due_date &&
|
||||||
|
deadline.original_due_date !== deadline.due_date && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
Urspruengliches Datum:{" "}
|
||||||
|
{format(parseISO(deadline.original_due_date), "d. MMMM yyyy", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{deadline.completed_at && (
|
||||||
|
<p className="mt-1 text-xs text-emerald-600">
|
||||||
|
Erledigt am:{" "}
|
||||||
|
{format(parseISO(deadline.completed_at), "d. MMMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Case context */}
|
||||||
|
{deadline.case_id && (
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Akte
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-900">
|
||||||
|
{deadline.case_number
|
||||||
|
? `Az. ${deadline.case_number}`
|
||||||
|
: "Verknuepfte Akte"}
|
||||||
|
{deadline.case_title && ` — ${deadline.case_title}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${deadline.case_id}`}
|
||||||
|
className="flex items-center gap-1 text-xs text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
Zur Akte
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source info */}
|
||||||
|
{deadline.source && deadline.source !== "manual" && (
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Quelle
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-700">
|
||||||
|
{deadline.source === "calculated"
|
||||||
|
? "Berechnet"
|
||||||
|
: deadline.source === "caldav"
|
||||||
|
? "CalDAV Sync"
|
||||||
|
: deadline.source}
|
||||||
|
{deadline.rule_id && ` (Regel: ${deadline.rule_id})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<NotesList parentType="deadline" parentId={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
frontend/src/app/(app)/fristen/neu/page.tsx
Normal file
180
frontend/src/app/(app)/fristen/neu/page.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Case, Deadline } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
const labelClass = "mb-1 block text-xs font-medium text-neutral-600";
|
||||||
|
|
||||||
|
export default function NewDeadlinePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [caseId, setCaseId] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [dueDate, setDueDate] = useState("");
|
||||||
|
const [warningDate, setWarningDate] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const { data: casesData } = useQuery({
|
||||||
|
queryKey: ["cases"],
|
||||||
|
queryFn: () => api.get<{ cases: Case[]; total: number } | Case[]>("/cases"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cases = Array.isArray(casesData)
|
||||||
|
? casesData
|
||||||
|
: Array.isArray(casesData?.cases)
|
||||||
|
? casesData.cases
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
api.post<Deadline>(`/cases/${caseId}/deadlines`, body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
toast.success("Frist erstellt");
|
||||||
|
router.push(`/fristen/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Erstellen der Frist"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!caseId || !title.trim() || !dueDate) return;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: title.trim(),
|
||||||
|
due_date: new Date(dueDate).toISOString(),
|
||||||
|
source: "manual",
|
||||||
|
};
|
||||||
|
if (description.trim()) body.description = description.trim();
|
||||||
|
if (warningDate) body.warning_date = new Date(warningDate).toISOString();
|
||||||
|
if (notes.trim()) body.notes = notes.trim();
|
||||||
|
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Fristen", href: "/fristen" },
|
||||||
|
{ label: "Neue Frist" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Neue Frist anlegen
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
|
Erstellen Sie eine neue Frist fuer eine Akte.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mt-6 max-w-lg space-y-4 rounded-lg border border-neutral-200 bg-white p-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Akte *</label>
|
||||||
|
<select
|
||||||
|
value={caseId}
|
||||||
|
onChange={(e) => setCaseId(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Akte auswaehlen...</option>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.case_number} — {c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Bezeichnung *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="z.B. Klageschrift einreichen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Beschreibung</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Optionale Beschreibung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Faellig am *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Warnung am</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={warningDate}
|
||||||
|
onChange={(e) => setWarningDate(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Notizen</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Optionale Notizen zur Frist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/fristen")}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || !caseId || !title.trim() || !dueDate
|
||||||
|
}
|
||||||
|
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "Erstellen..." : "Frist anlegen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,71 +2,83 @@
|
|||||||
|
|
||||||
import { DeadlineList } from "@/components/deadlines/DeadlineList";
|
import { DeadlineList } from "@/components/deadlines/DeadlineList";
|
||||||
import { DeadlineCalendarView } from "@/components/deadlines/DeadlineCalendarView";
|
import { DeadlineCalendarView } from "@/components/deadlines/DeadlineCalendarView";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Deadline } from "@/lib/types";
|
import type { Deadline } from "@/lib/types";
|
||||||
import { Calendar, List, Calculator } from "lucide-react";
|
import { Calendar, List, Calculator } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
type ViewMode = "list" | "calendar";
|
type ViewMode = "list" | "calendar";
|
||||||
|
|
||||||
export default function FristenPage() {
|
export default function FristenPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialStatus = searchParams.get("status") ?? undefined;
|
||||||
const [view, setView] = useState<ViewMode>("list");
|
const [view, setView] = useState<ViewMode>("list");
|
||||||
|
|
||||||
const { data: deadlines } = useQuery({
|
const { data: deadlines } = useQuery({
|
||||||
queryKey: ["deadlines"],
|
queryKey: ["deadlines"],
|
||||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-4">
|
<div className="animate-fade-in space-y-4">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div>
|
||||||
<div>
|
<Breadcrumb
|
||||||
<h1 className="text-lg font-semibold text-neutral-900">Fristen</h1>
|
items={[
|
||||||
<p className="mt-0.5 text-sm text-neutral-500">
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
Alle Fristen im Überblick
|
{ label: "Fristen" },
|
||||||
</p>
|
]}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Link
|
<div>
|
||||||
href="/fristen/rechner"
|
<h1 className="text-lg font-semibold text-neutral-900">Fristen</h1>
|
||||||
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
>
|
Alle Fristen im Überblick
|
||||||
<Calculator className="h-3.5 w-3.5" />
|
</p>
|
||||||
Fristenrechner
|
</div>
|
||||||
</Link>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex rounded-md border border-neutral-200 bg-white">
|
<Link
|
||||||
<button
|
href="/fristen/rechner"
|
||||||
onClick={() => setView("list")}
|
className="flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
className={`flex items-center gap-1 rounded-l-md px-2.5 py-1.5 text-sm transition-colors ${
|
|
||||||
view === "list"
|
|
||||||
? "bg-neutral-100 font-medium text-neutral-900"
|
|
||||||
: "text-neutral-500 hover:text-neutral-700"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<List className="h-3.5 w-3.5" />
|
<Calculator className="h-3.5 w-3.5" />
|
||||||
Liste
|
Fristenrechner
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<div className="flex rounded-md border border-neutral-200 bg-white">
|
||||||
onClick={() => setView("calendar")}
|
<button
|
||||||
className={`flex items-center gap-1 rounded-r-md px-2.5 py-1.5 text-sm transition-colors ${
|
onClick={() => setView("list")}
|
||||||
view === "calendar"
|
className={`flex items-center gap-1 rounded-l-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||||
? "bg-neutral-100 font-medium text-neutral-900"
|
view === "list"
|
||||||
: "text-neutral-500 hover:text-neutral-700"
|
? "bg-neutral-100 font-medium text-neutral-900"
|
||||||
}`}
|
: "text-neutral-500 hover:text-neutral-700"
|
||||||
>
|
}`}
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
>
|
||||||
Kalender
|
<List className="h-3.5 w-3.5" />
|
||||||
</button>
|
Liste
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView("calendar")}
|
||||||
|
className={`flex items-center gap-1 rounded-r-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||||
|
view === "calendar"
|
||||||
|
? "bg-neutral-100 font-medium text-neutral-900"
|
||||||
|
: "text-neutral-500 hover:text-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Calendar className="h-3.5 w-3.5" />
|
||||||
|
Kalender
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "list" ? (
|
{view === "list" ? (
|
||||||
<DeadlineList />
|
<DeadlineList initialStatus={initialStatus} />
|
||||||
) : (
|
) : (
|
||||||
<DeadlineCalendarView deadlines={deadlines || []} />
|
<DeadlineCalendarView deadlines={Array.isArray(deadlines) ? deadlines : []} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
201
frontend/src/app/(app)/termine/[id]/page.tsx
Normal file
201
frontend/src/app/(app)/termine/[id]/page.tsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Appointment } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { NotesList } from "@/components/notes/NotesList";
|
||||||
|
import { Skeleton } from "@/components/ui/Skeleton";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Calendar,
|
||||||
|
ExternalLink,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface AppointmentDetail extends Appointment {
|
||||||
|
case_number?: string;
|
||||||
|
case_title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<string, string> = {
|
||||||
|
hearing: "Verhandlung",
|
||||||
|
meeting: "Besprechung",
|
||||||
|
consultation: "Beratung",
|
||||||
|
deadline_hearing: "Fristanhoerung",
|
||||||
|
other: "Sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
hearing: "bg-blue-50 text-blue-700",
|
||||||
|
meeting: "bg-violet-50 text-violet-700",
|
||||||
|
consultation: "bg-emerald-50 text-emerald-700",
|
||||||
|
deadline_hearing: "bg-amber-50 text-amber-700",
|
||||||
|
other: "bg-neutral-100 text-neutral-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
<Skeleton className="h-32 rounded-lg" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppointmentDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: appointment,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["appointment", id],
|
||||||
|
queryFn: () => api.get<AppointmentDetail>(`/appointments/${id}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error || !appointment) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 w-fit rounded-xl bg-red-50 p-3">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
Termin nicht gefunden
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
|
Der Termin existiert nicht oder Sie haben keine Berechtigung.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/termine"
|
||||||
|
className="mt-4 inline-block text-sm text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
Zurueck zu Termine
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = parseISO(appointment.start_at);
|
||||||
|
const typeBadge = appointment.appointment_type
|
||||||
|
? TYPE_COLORS[appointment.appointment_type] ?? TYPE_COLORS.other
|
||||||
|
: null;
|
||||||
|
const typeLabel = appointment.appointment_type
|
||||||
|
? TYPE_LABELS[appointment.appointment_type] ?? appointment.appointment_type
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Termine", href: "/termine" },
|
||||||
|
{ label: appointment.title },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
{appointment.title}
|
||||||
|
</h1>
|
||||||
|
{typeBadge && typeLabel && (
|
||||||
|
<span
|
||||||
|
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${typeBadge}`}
|
||||||
|
>
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & Time */}
|
||||||
|
<div className="mt-4 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-neutral-400" />
|
||||||
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
|
{format(startDate, "EEEE, d. MMMM yyyy", { locale: de })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 pl-6 text-sm text-neutral-600">
|
||||||
|
{format(startDate, "HH:mm", { locale: de })} Uhr
|
||||||
|
{appointment.end_at && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
– {format(parseISO(appointment.end_at), "HH:mm", { locale: de })}{" "}
|
||||||
|
Uhr
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{appointment.location && (
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-neutral-400" />
|
||||||
|
<span className="text-sm text-neutral-900">
|
||||||
|
{appointment.location}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Case context */}
|
||||||
|
{appointment.case_id && (
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Akte
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-900">
|
||||||
|
{appointment.case_number
|
||||||
|
? `Az. ${appointment.case_number}`
|
||||||
|
: "Verknuepfte Akte"}
|
||||||
|
{appointment.case_title && ` — ${appointment.case_title}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${appointment.case_id}`}
|
||||||
|
className="flex items-center gap-1 text-xs text-neutral-500 transition-colors hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
Zur Akte
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{appointment.description && (
|
||||||
|
<div className="mt-3 rounded-lg border border-neutral-200 bg-white px-4 py-3">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
Beschreibung
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm text-neutral-700">
|
||||||
|
{appointment.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<NotesList parentType="appointment" parentId={id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/app/(app)/termine/neu/page.tsx
Normal file
206
frontend/src/app/(app)/termine/neu/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Case, Appointment } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const APPOINTMENT_TYPES = [
|
||||||
|
{ value: "hearing", label: "Verhandlung" },
|
||||||
|
{ value: "meeting", label: "Besprechung" },
|
||||||
|
{ value: "consultation", label: "Beratung" },
|
||||||
|
{ value: "deadline_hearing", label: "Fristanhoerung" },
|
||||||
|
{ value: "other", label: "Sonstiges" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"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";
|
||||||
|
const labelClass = "mb-1 block text-xs font-medium text-neutral-600";
|
||||||
|
|
||||||
|
export default function NewAppointmentPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [caseId, setCaseId] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [startAt, setStartAt] = useState("");
|
||||||
|
const [endAt, setEndAt] = useState("");
|
||||||
|
const [location, setLocation] = useState("");
|
||||||
|
const [appointmentType, setAppointmentType] = useState("");
|
||||||
|
|
||||||
|
const { data: casesData } = useQuery({
|
||||||
|
queryKey: ["cases"],
|
||||||
|
queryFn: () => api.get<{ cases: Case[]; total: number } | Case[]>("/cases"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cases = Array.isArray(casesData)
|
||||||
|
? casesData
|
||||||
|
: Array.isArray(casesData?.cases)
|
||||||
|
? casesData.cases
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
api.post<Appointment>("/appointments", body),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
toast.success("Termin erstellt");
|
||||||
|
router.push(`/termine/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Erstellen des Termins"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || !startAt) return;
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
title: title.trim(),
|
||||||
|
start_at: new Date(startAt).toISOString(),
|
||||||
|
};
|
||||||
|
if (description.trim()) body.description = description.trim();
|
||||||
|
if (endAt) body.end_at = new Date(endAt).toISOString();
|
||||||
|
if (location.trim()) body.location = location.trim();
|
||||||
|
if (appointmentType) body.appointment_type = appointmentType;
|
||||||
|
if (caseId) body.case_id = caseId;
|
||||||
|
|
||||||
|
createMutation.mutate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Termine", href: "/termine" },
|
||||||
|
{ label: "Neuer Termin" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Neuer Termin
|
||||||
|
</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-neutral-500">
|
||||||
|
Erstellen Sie einen neuen Termin.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="mt-6 max-w-lg space-y-4 rounded-lg border border-neutral-200 bg-white p-5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Titel *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="z.B. Muendliche Verhandlung"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Beginn *</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startAt}
|
||||||
|
onChange={(e) => setStartAt(e.target.value)}
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Ende</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={endAt}
|
||||||
|
onChange={(e) => setEndAt(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Typ</label>
|
||||||
|
<select
|
||||||
|
value={appointmentType}
|
||||||
|
onChange={(e) => setAppointmentType(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Kein Typ</option>
|
||||||
|
{APPOINTMENT_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Akte (optional)</label>
|
||||||
|
<select
|
||||||
|
value={caseId}
|
||||||
|
onChange={(e) => setCaseId(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Keine Akte</option>
|
||||||
|
{cases.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.case_number} — {c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Ort</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="z.B. UPC Muenchen, Saal 3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Optionale Beschreibung zum Termin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/termine")}
|
||||||
|
className="rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-sm text-neutral-700 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createMutation.isPending || !title.trim() || !startAt
|
||||||
|
}
|
||||||
|
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "Erstellen..." : "Termin anlegen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AppointmentModal } from "@/components/appointments/AppointmentModal";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { Appointment } from "@/lib/types";
|
import type { Appointment } from "@/lib/types";
|
||||||
|
import { Breadcrumb } from "@/components/layout/Breadcrumb";
|
||||||
import { Calendar, List, Plus } from "lucide-react";
|
import { Calendar, List, Plus } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export default function TerminePage() {
|
|||||||
|
|
||||||
const { data: appointments } = useQuery({
|
const { data: appointments } = useQuery({
|
||||||
queryKey: ["appointments"],
|
queryKey: ["appointments"],
|
||||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleEdit(appointment: Appointment) {
|
function handleEdit(appointment: Appointment) {
|
||||||
@@ -38,6 +39,12 @@ export default function TerminePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<Breadcrumb
|
||||||
|
items={[
|
||||||
|
{ label: "Dashboard", href: "/dashboard" },
|
||||||
|
{ label: "Termine" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-neutral-900">Termine</h1>
|
<h1 className="text-lg font-semibold text-neutral-900">Termine</h1>
|
||||||
@@ -84,7 +91,7 @@ export default function TerminePage() {
|
|||||||
<AppointmentList onEdit={handleEdit} />
|
<AppointmentList onEdit={handleEdit} />
|
||||||
) : (
|
) : (
|
||||||
<AppointmentCalendar
|
<AppointmentCalendar
|
||||||
appointments={appointments || []}
|
appointments={Array.isArray(appointments) ? appointments : []}
|
||||||
onAppointmentClick={handleEdit}
|
onAppointmentClick={handleEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -54,16 +54,16 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
|||||||
|
|
||||||
const { data: appointments, isLoading } = useQuery({
|
const { data: appointments, isLoading } = useQuery({
|
||||||
queryKey: ["appointments"],
|
queryKey: ["appointments"],
|
||||||
queryFn: () => api.get<Appointment[]>("/api/appointments"),
|
queryFn: () => api.get<Appointment[]>("/appointments"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: cases } = useQuery({
|
const { data: cases } = useQuery({
|
||||||
queryKey: ["cases"],
|
queryKey: ["cases"],
|
||||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete(`/api/appointments/${id}`),
|
mutationFn: (id: string) => api.delete(`/appointments/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
toast.success("Termin geloscht");
|
toast.success("Termin geloscht");
|
||||||
@@ -73,12 +73,13 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
|||||||
|
|
||||||
const caseMap = useMemo(() => {
|
const caseMap = useMemo(() => {
|
||||||
const map = new Map<string, Case>();
|
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;
|
return map;
|
||||||
}, [cases]);
|
}, [cases]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!appointments) return [];
|
if (!Array.isArray(appointments)) return [];
|
||||||
return appointments
|
return appointments
|
||||||
.filter((a) => {
|
.filter((a) => {
|
||||||
if (caseFilter !== "all" && a.case_id !== caseFilter) return false;
|
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 grouped = useMemo(() => groupByDate(filtered), [filtered]);
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
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 today = 0;
|
||||||
let thisWeek = 0;
|
let thisWeek = 0;
|
||||||
for (const a of appointments) {
|
for (const a of appointments) {
|
||||||
@@ -148,7 +149,7 @@ export function AppointmentList({ onEdit }: AppointmentListProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{cases?.cases && cases.cases.length > 0 && (
|
{Array.isArray(cases?.cases) && cases.cases.length > 0 && (
|
||||||
<select
|
<select
|
||||||
value={caseFilter}
|
value={caseFilter}
|
||||||
onChange={(e) => setCaseFilter(e.target.value)}
|
onChange={(e) => setCaseFilter(e.target.value)}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
|||||||
|
|
||||||
const { data: cases } = useQuery({
|
const { data: cases } = useQuery({
|
||||||
queryKey: ["cases"],
|
queryKey: ["cases"],
|
||||||
queryFn: () => api.get<{ cases: Case[]; total: number }>("/api/cases"),
|
queryFn: () => api.get<{ cases: Case[]; total: number }>("/cases"),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -66,7 +66,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
api.post<Appointment>("/api/appointments", body),
|
api.post<Appointment>("/appointments", body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
@@ -78,7 +78,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
|||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
api.put<Appointment>(`/api/appointments/${appointment!.id}`, body),
|
api.put<Appointment>(`/appointments/${appointment!.id}`, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
@@ -89,7 +89,7 @@ export function AppointmentModal({ open, onClose, appointment }: AppointmentModa
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: () => api.delete(`/api/appointments/${appointment!.id}`),
|
mutationFn: () => api.delete(`/appointments/${appointment!.id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Sparkles } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { Sparkles, RefreshCw } from "lucide-react";
|
||||||
import type { DashboardData } from "@/lib/types";
|
import type { DashboardData } from "@/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: DashboardData;
|
data: DashboardData;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSummary(data: DashboardData): string {
|
function generateSummary(data: DashboardData): string {
|
||||||
const parts: 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
|
// Deadline urgency
|
||||||
if (ds.overdue_count > 0) {
|
if (ds.overdue_count > 0) {
|
||||||
@@ -49,18 +53,39 @@ function generateSummary(data: DashboardData): string {
|
|||||||
return parts.join(" ");
|
return parts.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AISummaryCard({ data }: Props) {
|
export function AISummaryCard({ data, onRefresh }: Props) {
|
||||||
|
const [spinning, setSpinning] = useState(false);
|
||||||
const summary = generateSummary(data);
|
const summary = generateSummary(data);
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
if (!onRefresh) return;
|
||||||
|
setSpinning(true);
|
||||||
|
onRefresh();
|
||||||
|
setTimeout(() => setSpinning(false), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<div className="rounded-md bg-violet-50 p-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-violet-500" />
|
<div className="rounded-md bg-violet-50 p-1.5">
|
||||||
|
<Sparkles className="h-4 w-4 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">
|
||||||
|
KI-Zusammenfassung
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-sm font-semibold text-neutral-900">
|
{onRefresh && (
|
||||||
KI-Zusammenfassung
|
<button
|
||||||
</h2>
|
onClick={handleRefresh}
|
||||||
|
title="Aktualisieren"
|
||||||
|
className="rounded-md p-1.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${spinning ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-neutral-700">
|
<p className="mt-3 text-sm leading-relaxed text-neutral-700">
|
||||||
{summary}
|
{summary}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FolderOpen, FolderPlus, Archive } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { FolderOpen, FolderPlus, Archive, ChevronRight } from "lucide-react";
|
||||||
import type { CaseSummary } from "@/lib/types";
|
import type { CaseSummary } from "@/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,46 +9,57 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CaseOverviewGrid({ data }: Props) {
|
export function CaseOverviewGrid({ data }: Props) {
|
||||||
|
const safe = data ?? { active_count: 0, new_this_month: 0, closed_count: 0 };
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
label: "Aktive Akten",
|
label: "Aktive Akten",
|
||||||
value: data.active_count,
|
value: safe.active_count ?? 0,
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
color: "text-blue-600",
|
color: "text-blue-600",
|
||||||
bg: "bg-blue-50",
|
bg: "bg-blue-50",
|
||||||
|
href: "/cases?status=active",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Neu (Monat)",
|
label: "Neu (Monat)",
|
||||||
value: data.new_this_month,
|
value: safe.new_this_month ?? 0,
|
||||||
icon: FolderPlus,
|
icon: FolderPlus,
|
||||||
color: "text-violet-600",
|
color: "text-violet-600",
|
||||||
bg: "bg-violet-50",
|
bg: "bg-violet-50",
|
||||||
|
href: "/cases?status=active&since=month",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Abgeschlossen",
|
label: "Abgeschlossen",
|
||||||
value: data.closed_count,
|
value: safe.closed_count ?? 0,
|
||||||
icon: Archive,
|
icon: Archive,
|
||||||
color: "text-neutral-500",
|
color: "text-neutral-500",
|
||||||
bg: "bg-neutral-50",
|
bg: "bg-neutral-50",
|
||||||
|
href: "/cases?status=closed",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||||
<h2 className="text-sm font-semibold text-neutral-900">Aktenübersicht</h2>
|
<h2 className="text-sm font-semibold text-neutral-900">Aktenübersicht</h2>
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-1">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.label} className="flex items-center justify-between">
|
<Link
|
||||||
|
key={item.label}
|
||||||
|
href={item.href}
|
||||||
|
className="group -mx-2 flex items-center justify-between rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className={`rounded-md p-1.5 ${item.bg}`}>
|
<div className={`rounded-md p-1.5 ${item.bg}`}>
|
||||||
<item.icon className={`h-4 w-4 ${item.color}`} />
|
<item.icon className={`h-4 w-4 ${item.color}`} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-neutral-600">{item.label}</span>
|
<span className="text-sm text-neutral-600">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-semibold tabular-nums text-neutral-900">
|
<div className="flex items-center gap-1.5">
|
||||||
{item.value}
|
<span className="text-lg font-semibold tabular-nums text-neutral-900">
|
||||||
</span>
|
{item.value}
|
||||||
</div>
|
</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-neutral-300 transition-colors group-hover:text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { AlertTriangle, Clock, CheckCircle } from "lucide-react";
|
import { AlertTriangle, Clock, CheckCircle } from "lucide-react";
|
||||||
import type { DeadlineSummary } from "@/lib/types";
|
import type { DeadlineSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -27,29 +28,31 @@ function AnimatedCount({ value }: { value: number }) {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: DeadlineSummary;
|
data: DeadlineSummary;
|
||||||
onFilter?: (filter: "overdue" | "this_week" | "ok") => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
export function DeadlineTrafficLights({ data }: Props) {
|
||||||
|
const safe = data ?? { overdue_count: 0, due_this_week: 0, due_next_week: 0, ok_count: 0 };
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
key: "overdue" as const,
|
key: "overdue" as const,
|
||||||
label: "Überfällig",
|
label: "Überfällig",
|
||||||
count: data.overdue_count,
|
count: safe.overdue_count ?? 0,
|
||||||
icon: AlertTriangle,
|
icon: AlertTriangle,
|
||||||
|
href: "/fristen?status=overdue",
|
||||||
bg: "bg-red-50",
|
bg: "bg-red-50",
|
||||||
border: "border-red-200",
|
border: "border-red-200",
|
||||||
iconColor: "text-red-500",
|
iconColor: "text-red-500",
|
||||||
countColor: "text-red-700",
|
countColor: "text-red-700",
|
||||||
labelColor: "text-red-600",
|
labelColor: "text-red-600",
|
||||||
ring: data.overdue_count > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
|
ring: (safe.overdue_count ?? 0) > 0 ? "ring-2 ring-red-300 ring-offset-1" : "",
|
||||||
pulse: data.overdue_count > 0,
|
pulse: (safe.overdue_count ?? 0) > 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "this_week" as const,
|
key: "this_week" as const,
|
||||||
label: "Diese Woche",
|
label: "Diese Woche",
|
||||||
count: data.due_this_week,
|
count: safe.due_this_week ?? 0,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
|
href: "/fristen?status=this_week",
|
||||||
bg: "bg-amber-50",
|
bg: "bg-amber-50",
|
||||||
border: "border-amber-200",
|
border: "border-amber-200",
|
||||||
iconColor: "text-amber-500",
|
iconColor: "text-amber-500",
|
||||||
@@ -61,8 +64,9 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
|||||||
{
|
{
|
||||||
key: "ok" as const,
|
key: "ok" as const,
|
||||||
label: "Im Zeitplan",
|
label: "Im Zeitplan",
|
||||||
count: data.ok_count + data.due_next_week,
|
count: (safe.ok_count ?? 0) + (safe.due_next_week ?? 0),
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
|
href: "/fristen?status=ok",
|
||||||
bg: "bg-emerald-50",
|
bg: "bg-emerald-50",
|
||||||
border: "border-emerald-200",
|
border: "border-emerald-200",
|
||||||
iconColor: "text-emerald-500",
|
iconColor: "text-emerald-500",
|
||||||
@@ -76,9 +80,9 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<button
|
<Link
|
||||||
key={card.key}
|
key={card.key}
|
||||||
onClick={() => onFilter?.(card.key)}
|
href={card.href}
|
||||||
className={`group relative overflow-hidden rounded-xl border ${card.border} ${card.bg} ${card.ring} p-6 text-left transition-all hover:shadow-md active:scale-[0.98]`}
|
className={`group relative overflow-hidden rounded-xl border ${card.border} ${card.bg} ${card.ring} p-6 text-left transition-all hover:shadow-md active:scale-[0.98]`}
|
||||||
>
|
>
|
||||||
{card.pulse && (
|
{card.pulse && (
|
||||||
@@ -98,7 +102,7 @@ export function DeadlineTrafficLights({ data, onFilter }: Props) {
|
|||||||
<div className={`mt-4 text-4xl font-bold tracking-tight ${card.countColor}`}>
|
<div className={`mt-4 text-4xl font-bold tracking-tight ${card.countColor}`}>
|
||||||
<AnimatedCount value={card.count} />
|
<AnimatedCount value={card.count} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FolderPlus, Clock, Sparkles, CalendarSync } from "lucide-react";
|
import { FolderPlus, Clock, Sparkles, CalendarPlus } from "lucide-react";
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
@@ -12,22 +12,22 @@ const actions = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Frist eintragen",
|
label: "Frist eintragen",
|
||||||
href: "/fristen",
|
href: "/fristen/neu",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
color: "text-amber-600 bg-amber-50 hover:bg-amber-100",
|
color: "text-amber-600 bg-amber-50 hover:bg-amber-100",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Neuer Termin",
|
||||||
|
href: "/termine/neu",
|
||||||
|
icon: CalendarPlus,
|
||||||
|
color: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "AI Analyse",
|
label: "AI Analyse",
|
||||||
href: "/ai/extract",
|
href: "/ai/extract",
|
||||||
icon: Sparkles,
|
icon: Sparkles,
|
||||||
color: "text-violet-600 bg-violet-50 hover:bg-violet-100",
|
color: "text-violet-600 bg-violet-50 hover:bg-violet-100",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "CalDAV Sync",
|
|
||||||
href: "/einstellungen",
|
|
||||||
icon: CalendarSync,
|
|
||||||
color: "text-emerald-600 bg-emerald-50 hover:bg-emerald-100",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function QuickActions() {
|
export function QuickActions() {
|
||||||
|
|||||||
80
frontend/src/components/dashboard/RecentActivityList.tsx
Normal file
80
frontend/src/components/dashboard/RecentActivityList.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { formatDistanceToNow, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Scale,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
MessageSquare,
|
||||||
|
ChevronRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { RecentActivity } from "@/lib/types";
|
||||||
|
|
||||||
|
const EVENT_ICONS: Record<string, typeof FileText> = {
|
||||||
|
status_changed: Scale,
|
||||||
|
deadline_created: Clock,
|
||||||
|
appointment_created: Calendar,
|
||||||
|
document_uploaded: FileText,
|
||||||
|
note_added: MessageSquare,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activities: RecentActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivityList({ activities }: Props) {
|
||||||
|
const safe = Array.isArray(activities) ? activities : [];
|
||||||
|
|
||||||
|
if (safe.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-neutral-200 bg-white p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900">
|
||||||
|
Letzte Aktivität
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3 divide-y divide-neutral-100">
|
||||||
|
{safe.map((activity) => {
|
||||||
|
const Icon = EVENT_ICONS[activity.event_type ?? ""] ?? FileText;
|
||||||
|
const timeAgo = activity.created_at
|
||||||
|
? formatDistanceToNow(parseISO(activity.created_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: de,
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={activity.id}
|
||||||
|
href={`/cases/${activity.case_id}`}
|
||||||
|
className="group flex items-center gap-3 py-2.5 transition-colors first:pt-0 last:pb-0 hover:bg-neutral-50 -mx-5 px-5"
|
||||||
|
>
|
||||||
|
<div className="rounded-md bg-neutral-100 p-1.5">
|
||||||
|
<Icon className="h-3.5 w-3.5 text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm text-neutral-900">
|
||||||
|
{activity.title}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span>{activity.case_number}</span>
|
||||||
|
{timeAgo && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span>{timeAgo}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-neutral-300 transition-colors group-hover:text-neutral-500" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
import { format, parseISO, isToday, isTomorrow } from "date-fns";
|
||||||
import { de } from "date-fns/locale";
|
import { de } from "date-fns/locale";
|
||||||
import { Clock, Calendar, MapPin } from "lucide-react";
|
import { Clock, Calendar, MapPin, ChevronRight } from "lucide-react";
|
||||||
import type { UpcomingDeadline, UpcomingAppointment } from "@/lib/types";
|
import type { UpcomingDeadline, UpcomingAppointment } from "@/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,13 +22,16 @@ function formatDayLabel(date: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UpcomingTimeline({ deadlines, appointments }: Props) {
|
export function UpcomingTimeline({ deadlines, appointments }: Props) {
|
||||||
|
const safeDeadlines = Array.isArray(deadlines) ? deadlines : [];
|
||||||
|
const safeAppointments = Array.isArray(appointments) ? appointments : [];
|
||||||
|
|
||||||
const items: TimelineItem[] = [
|
const items: TimelineItem[] = [
|
||||||
...deadlines.map((d) => ({
|
...safeDeadlines.map((d) => ({
|
||||||
type: "deadline" as const,
|
type: "deadline" as const,
|
||||||
date: parseISO(d.due_date),
|
date: parseISO(d.due_date),
|
||||||
data: d,
|
data: d,
|
||||||
})),
|
})),
|
||||||
...appointments.map((a) => ({
|
...safeAppointments.map((a) => ({
|
||||||
type: "appointment" as const,
|
type: "appointment" as const,
|
||||||
date: parseISO(a.start_at),
|
date: parseISO(a.start_at),
|
||||||
data: a,
|
data: a,
|
||||||
@@ -77,8 +81,12 @@ export function UpcomingTimeline({ deadlines, appointments }: Props) {
|
|||||||
function TimelineEntry({ item }: { item: TimelineItem }) {
|
function TimelineEntry({ item }: { item: TimelineItem }) {
|
||||||
if (item.type === "deadline") {
|
if (item.type === "deadline") {
|
||||||
const d = item.data;
|
const d = item.data;
|
||||||
|
const href = `/fristen/${d.id}`;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5 transition-colors hover:border-neutral-200 hover:bg-neutral-100/50"
|
||||||
|
>
|
||||||
<div className="mt-0.5 rounded-md bg-amber-50 p-1">
|
<div className="mt-0.5 rounded-md bg-amber-50 p-1">
|
||||||
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
<Clock className="h-3.5 w-3.5 text-amber-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -87,19 +95,40 @@ function TimelineEntry({ item }: { item: TimelineItem }) {
|
|||||||
{d.title}
|
{d.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
<p className="mt-0.5 truncate text-xs text-neutral-500">
|
||||||
{d.case_number} · {d.case_title}
|
{d.case_id ? (
|
||||||
|
<span
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="inline"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${d.case_id}`}
|
||||||
|
className="underline decoration-neutral-300 hover:text-neutral-900 hover:decoration-neutral-500"
|
||||||
|
>
|
||||||
|
{d.case_number}
|
||||||
|
</Link>
|
||||||
|
{" · "}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>{d.case_number} · </>
|
||||||
|
)}
|
||||||
|
{d.case_title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-medium text-amber-600">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
Frist
|
<span className="text-xs font-medium text-amber-600">Frist</span>
|
||||||
</span>
|
<ChevronRight className="h-3.5 w-3.5 text-neutral-300 transition-colors group-hover:text-neutral-500" />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const a = item.data;
|
const a = item.data;
|
||||||
|
const href = `/termine/${a.id}`;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5">
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50/50 px-3 py-2.5 transition-colors hover:border-neutral-200 hover:bg-neutral-100/50"
|
||||||
|
>
|
||||||
<div className="mt-0.5 rounded-md bg-blue-50 p-1">
|
<div className="mt-0.5 rounded-md bg-blue-50 p-1">
|
||||||
<Calendar className="h-3.5 w-3.5 text-blue-500" />
|
<Calendar className="h-3.5 w-3.5 text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -118,7 +147,20 @@ function TimelineEntry({ item }: { item: TimelineItem }) {
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{a.case_number && (
|
{a.case_number && a.case_id && (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-300">·</span>
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Link
|
||||||
|
href={`/cases/${a.case_id}`}
|
||||||
|
className="underline decoration-neutral-300 hover:text-neutral-900 hover:decoration-neutral-500"
|
||||||
|
>
|
||||||
|
{a.case_number}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{a.case_number && !a.case_id && (
|
||||||
<>
|
<>
|
||||||
<span className="text-neutral-300">·</span>
|
<span className="text-neutral-300">·</span>
|
||||||
<span>{a.case_number}</span>
|
<span>{a.case_number}</span>
|
||||||
@@ -126,9 +168,10 @@ function TimelineEntry({ item }: { item: TimelineItem }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-xs font-medium text-blue-600">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
Termin
|
<span className="text-xs font-medium text-blue-600">Termin</span>
|
||||||
</span>
|
<ChevronRight className="h-3.5 w-3.5 text-neutral-300 transition-colors group-hover:text-neutral-500" />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ export function DeadlineCalculator() {
|
|||||||
|
|
||||||
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
const { data: proceedingTypes, isLoading: typesLoading } = useQuery({
|
||||||
queryKey: ["proceeding-types"],
|
queryKey: ["proceeding-types"],
|
||||||
queryFn: () => api.get<ProceedingType[]>("/api/proceeding-types"),
|
queryFn: () => api.get<ProceedingType[]>("/proceeding-types"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const calculateMutation = useMutation({
|
const calculateMutation = useMutation({
|
||||||
mutationFn: (params: {
|
mutationFn: (params: {
|
||||||
proceeding_type: string;
|
proceeding_type: string;
|
||||||
trigger_event_date: string;
|
trigger_event_date: string;
|
||||||
}) => api.post<CalculateResponse>("/api/deadlines/calculate", params),
|
}) => api.post<CalculateResponse>("/deadlines/calculate", params),
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleCalculate(e: React.FormEvent) {
|
function handleCalculate(e: React.FormEvent) {
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ import { toast } from "sonner";
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { EmptyState } from "@/components/ui/EmptyState";
|
import { EmptyState } from "@/components/ui/EmptyState";
|
||||||
|
|
||||||
type StatusFilter = "all" | "pending" | "completed" | "overdue";
|
type StatusFilter = "all" | "pending" | "completed" | "overdue" | "this_week" | "ok";
|
||||||
|
|
||||||
|
function mapUrlStatus(status?: string): StatusFilter {
|
||||||
|
if (status === "overdue") return "overdue";
|
||||||
|
if (status === "this_week") return "this_week";
|
||||||
|
if (status === "ok") return "ok";
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
|
||||||
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
|
function getUrgency(deadline: Deadline): "red" | "amber" | "green" {
|
||||||
if (deadline.status === "completed") return "green";
|
if (deadline.status === "completed") return "green";
|
||||||
@@ -47,24 +54,30 @@ const urgencyConfig = {
|
|||||||
const selectClass =
|
const selectClass =
|
||||||
"rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700 transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 outline-none";
|
"rounded-md border border-neutral-200 bg-white px-2.5 py-1 text-sm text-neutral-700 transition-colors focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400 outline-none";
|
||||||
|
|
||||||
export function DeadlineList() {
|
interface Props {
|
||||||
|
initialStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeadlineList({ initialStatus }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
|
||||||
|
mapUrlStatus(initialStatus),
|
||||||
|
);
|
||||||
const [caseFilter, setCaseFilter] = useState<string>("all");
|
const [caseFilter, setCaseFilter] = useState<string>("all");
|
||||||
|
|
||||||
const { data: deadlines, isLoading } = useQuery({
|
const { data: deadlines, isLoading } = useQuery({
|
||||||
queryKey: ["deadlines"],
|
queryKey: ["deadlines"],
|
||||||
queryFn: () => api.get<Deadline[]>("/api/deadlines"),
|
queryFn: () => api.get<Deadline[]>("/deadlines"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: cases } = useQuery({
|
const { data: cases } = useQuery({
|
||||||
queryKey: ["cases"],
|
queryKey: ["cases"],
|
||||||
queryFn: () => api.get<Case[]>("/api/cases"),
|
queryFn: () => api.get<Case[]>("/cases"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
api.patch<Deadline>(`/api/deadlines/${id}/complete`),
|
api.patch<Deadline>(`/deadlines/${id}/complete`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
queryClient.invalidateQueries({ queryKey: ["deadlines"] });
|
||||||
toast.success("Frist als erledigt markiert");
|
toast.success("Frist als erledigt markiert");
|
||||||
@@ -76,12 +89,12 @@ export function DeadlineList() {
|
|||||||
|
|
||||||
const caseMap = useMemo(() => {
|
const caseMap = useMemo(() => {
|
||||||
const map = new Map<string, Case>();
|
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;
|
return map;
|
||||||
}, [cases]);
|
}, [cases]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!deadlines) return [];
|
if (!Array.isArray(deadlines)) return [];
|
||||||
return deadlines.filter((d) => {
|
return deadlines.filter((d) => {
|
||||||
if (statusFilter === "pending" && d.status !== "pending") return false;
|
if (statusFilter === "pending" && d.status !== "pending") return false;
|
||||||
if (statusFilter === "completed" && d.status !== "completed")
|
if (statusFilter === "completed" && d.status !== "completed")
|
||||||
@@ -90,13 +103,25 @@ export function DeadlineList() {
|
|||||||
if (d.status === "completed") return false;
|
if (d.status === "completed") return false;
|
||||||
if (!isPast(parseISO(d.due_date))) return false;
|
if (!isPast(parseISO(d.due_date))) return false;
|
||||||
}
|
}
|
||||||
|
if (statusFilter === "this_week") {
|
||||||
|
if (d.status === "completed") return false;
|
||||||
|
const due = parseISO(d.due_date);
|
||||||
|
if (isPast(due)) return false;
|
||||||
|
if (!isThisWeek(due, { weekStartsOn: 1 })) return false;
|
||||||
|
}
|
||||||
|
if (statusFilter === "ok") {
|
||||||
|
if (d.status === "completed") return false;
|
||||||
|
const due = parseISO(d.due_date);
|
||||||
|
if (isPast(due)) return false;
|
||||||
|
if (isThisWeek(due, { weekStartsOn: 1 })) return false;
|
||||||
|
}
|
||||||
if (caseFilter !== "all" && d.case_id !== caseFilter) return false;
|
if (caseFilter !== "all" && d.case_id !== caseFilter) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [deadlines, statusFilter, caseFilter]);
|
}, [deadlines, statusFilter, caseFilter]);
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
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,
|
let overdue = 0,
|
||||||
thisWeek = 0,
|
thisWeek = 0,
|
||||||
ok = 0;
|
ok = 0;
|
||||||
@@ -144,10 +169,10 @@ export function DeadlineList() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setStatusFilter(statusFilter === "pending" ? "all" : "pending")
|
setStatusFilter(statusFilter === "this_week" ? "all" : "this_week")
|
||||||
}
|
}
|
||||||
className={`rounded-lg border p-3 text-left transition-all ${
|
className={`rounded-lg border p-3 text-left transition-all ${
|
||||||
statusFilter === "pending"
|
statusFilter === "this_week"
|
||||||
? "border-amber-300 bg-amber-50 ring-1 ring-amber-200"
|
? "border-amber-300 bg-amber-50 ring-1 ring-amber-200"
|
||||||
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
||||||
}`}
|
}`}
|
||||||
@@ -158,9 +183,11 @@ export function DeadlineList() {
|
|||||||
<div className="text-xs text-neutral-500">Diese Woche</div>
|
<div className="text-xs text-neutral-500">Diese Woche</div>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter("all")}
|
onClick={() =>
|
||||||
|
setStatusFilter(statusFilter === "ok" ? "all" : "ok")
|
||||||
|
}
|
||||||
className={`rounded-lg border p-3 text-left transition-all ${
|
className={`rounded-lg border p-3 text-left transition-all ${
|
||||||
statusFilter === "all"
|
statusFilter === "ok"
|
||||||
? "border-green-300 bg-green-50 ring-1 ring-green-200"
|
? "border-green-300 bg-green-50 ring-1 ring-green-200"
|
||||||
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
: "border-neutral-200 bg-white hover:bg-neutral-50"
|
||||||
}`}
|
}`}
|
||||||
@@ -187,8 +214,10 @@ export function DeadlineList() {
|
|||||||
<option value="pending">Offen</option>
|
<option value="pending">Offen</option>
|
||||||
<option value="completed">Erledigt</option>
|
<option value="completed">Erledigt</option>
|
||||||
<option value="overdue">Überfällig</option>
|
<option value="overdue">Überfällig</option>
|
||||||
|
<option value="this_week">Diese Woche</option>
|
||||||
|
<option value="ok">Im Zeitplan</option>
|
||||||
</select>
|
</select>
|
||||||
{cases && cases.length > 0 && (
|
{Array.isArray(cases) && cases.length > 0 && (
|
||||||
<select
|
<select
|
||||||
value={caseFilter}
|
value={caseFilter}
|
||||||
onChange={(e) => setCaseFilter(e.target.value)}
|
onChange={(e) => setCaseFilter(e.target.value)}
|
||||||
|
|||||||
38
frontend/src/components/layout/Breadcrumb.tsx
Normal file
38
frontend/src/components/layout/Breadcrumb.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Breadcrumb({ items }: Props) {
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="mb-4 flex items-center gap-1 text-sm text-neutral-500">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const isLast = i === items.length - 1;
|
||||||
|
return (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <ChevronRight className="h-3.5 w-3.5 text-neutral-300" />}
|
||||||
|
{isLast || !item.href ? (
|
||||||
|
<span className={isLast ? "font-medium text-neutral-900" : ""}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="transition-colors hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export function TenantSwitcher() {
|
|||||||
api
|
api
|
||||||
.get<TenantWithRole[]>("/tenants")
|
.get<TenantWithRole[]>("/tenants")
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
setTenants(data);
|
setTenants(data);
|
||||||
const savedId = localStorage.getItem("kanzlai_tenant_id");
|
const savedId = localStorage.getItem("kanzlai_tenant_id");
|
||||||
const match = data.find((t) => t.id === savedId) || data[0];
|
const match = data.find((t) => t.id === savedId) || data[0];
|
||||||
@@ -53,30 +54,33 @@ export function TenantSwitcher() {
|
|||||||
onClick={() => setOpen(!open)}
|
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"
|
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}
|
{current.name}
|
||||||
</span>
|
</span>
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
|
<ChevronsUpDown className="h-3.5 w-3.5 text-neutral-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && tenants.length > 1 && (
|
{open && (
|
||||||
<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">
|
<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) => (
|
{tenants.map((tenant) => (
|
||||||
<button
|
<button
|
||||||
key={tenant.id}
|
key={tenant.id}
|
||||||
onClick={() => switchTenant(tenant)}
|
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
|
tenant.id === current.id
|
||||||
? "bg-neutral-50 font-medium text-neutral-900"
|
? "bg-neutral-50 font-medium text-neutral-900"
|
||||||
: "text-neutral-600 hover:bg-neutral-50"
|
: "text-neutral-600 hover:bg-neutral-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{tenant.name}</span>
|
<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}
|
{tenant.role}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{tenants.length <= 1 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-neutral-400">Einzige Kanzlei</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
209
frontend/src/components/notes/NotesList.tsx
Normal file
209
frontend/src/components/notes/NotesList.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Note } from "@/lib/types";
|
||||||
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { de } from "date-fns/locale";
|
||||||
|
import { Plus, Pencil, Trash2, X, Check, MessageSquare } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface NotesListProps {
|
||||||
|
parentType: "case" | "deadline" | "appointment" | "case_event";
|
||||||
|
parentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotesList({ parentType, parentId }: NotesListProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = ["notes", parentType, parentId];
|
||||||
|
|
||||||
|
const [newContent, setNewContent] = useState("");
|
||||||
|
const [showNew, setShowNew] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editContent, setEditContent] = useState("");
|
||||||
|
|
||||||
|
const { data: notes, isLoading } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<Note[]>(`/notes?${parentType}_id=${parentId}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => {
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
content,
|
||||||
|
[`${parentType}_id`]: parentId,
|
||||||
|
};
|
||||||
|
return api.post<Note>("/notes", body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
setNewContent("");
|
||||||
|
setShowNew(false);
|
||||||
|
toast.success("Notiz erstellt");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Erstellen der Notiz"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, content }: { id: string; content: string }) =>
|
||||||
|
api.put<Note>(`/notes/${id}`, { content }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
setEditingId(null);
|
||||||
|
toast.success("Notiz aktualisiert");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Aktualisieren der Notiz"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => api.delete(`/notes/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey });
|
||||||
|
toast.success("Notiz geloescht");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Fehler beim Loeschen der Notiz"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!newContent.trim()) return;
|
||||||
|
createMutation.mutate(newContent.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdate(id: string) {
|
||||||
|
if (!editContent.trim()) return;
|
||||||
|
updateMutation.mutate({ id, content: editContent.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(note: Note) {
|
||||||
|
setEditingId(note.id);
|
||||||
|
setEditContent(note.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesList = Array.isArray(notes) ? notes : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between border-b border-neutral-100 px-4 py-3">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900">Notizen</h3>
|
||||||
|
{!showNew && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNew(true)}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-neutral-500 transition-colors hover:bg-neutral-50 hover:text-neutral-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Neu
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNew && (
|
||||||
|
<div className="border-b border-neutral-100 p-4">
|
||||||
|
<textarea
|
||||||
|
value={newContent}
|
||||||
|
onChange={(e) => setNewContent(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
placeholder="Notiz schreiben..."
|
||||||
|
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowNew(false);
|
||||||
|
setNewContent("");
|
||||||
|
}}
|
||||||
|
className="rounded-md px-2.5 py-1 text-xs text-neutral-500 hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!newContent.trim() || createMutation.isPending}
|
||||||
|
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "Speichern..." : "Speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i} className="h-12 animate-pulse rounded-md bg-neutral-100" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : notesList.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<MessageSquare className="h-5 w-5 text-neutral-300" />
|
||||||
|
<p className="mt-2 text-sm text-neutral-400">
|
||||||
|
Keine Notizen vorhanden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-neutral-100">
|
||||||
|
{notesList.map((note) => (
|
||||||
|
<div key={note.id} className="group px-4 py-3">
|
||||||
|
{editingId === note.id ? (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
value={editContent}
|
||||||
|
onChange={(e) => setEditContent(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-md border border-neutral-200 px-3 py-2 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-50 hover:text-neutral-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdate(note.id)}
|
||||||
|
disabled={!editContent.trim() || updateMutation.isPending}
|
||||||
|
className="rounded-md p-1 text-neutral-400 hover:bg-neutral-50 hover:text-green-600"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-neutral-700">
|
||||||
|
{note.content}
|
||||||
|
</p>
|
||||||
|
<div className="ml-4 flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={() => startEdit(note)}
|
||||||
|
className="rounded p-1 text-neutral-400 hover:bg-neutral-50 hover:text-neutral-600"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate(note.id)}
|
||||||
|
className="rounded p-1 text-neutral-400 hover:bg-red-50 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-neutral-400">
|
||||||
|
{format(parseISO(note.created_at), "d. MMM yyyy, HH:mm", {
|
||||||
|
locale: de,
|
||||||
|
})}
|
||||||
|
{note.updated_at !== note.created_at && " (bearbeitet)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
|
|||||||
// Fetch sync status
|
// Fetch sync status
|
||||||
const { data: syncStatus } = useQuery({
|
const { data: syncStatus } = useQuery({
|
||||||
queryKey: ["caldav-status"],
|
queryKey: ["caldav-status"],
|
||||||
queryFn: () => api.get<CalDAVSyncResponse>("/api/caldav/status"),
|
queryFn: () => api.get<CalDAVSyncResponse>("/caldav/status"),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
|
|||||||
typeof window !== "undefined"
|
typeof window !== "undefined"
|
||||||
? localStorage.getItem("kanzlai_tenant_id")
|
? localStorage.getItem("kanzlai_tenant_id")
|
||||||
: null;
|
: null;
|
||||||
return api.put<Tenant>(`/api/tenants/${tenantId}/settings`, {
|
return api.put<Tenant>(`/tenants/${tenantId}/settings`, {
|
||||||
caldav: cfg,
|
caldav: cfg,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -83,7 +83,7 @@ export function CalDAVSettings({ tenant }: { tenant: Tenant }) {
|
|||||||
|
|
||||||
// Trigger sync
|
// Trigger sync
|
||||||
const syncMutation = useMutation({
|
const syncMutation = useMutation({
|
||||||
mutationFn: () => api.post<CalDAVSyncResponse>("/api/caldav/sync"),
|
mutationFn: () => api.post<CalDAVSyncResponse>("/caldav/sync"),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["caldav-status"] });
|
queryClient.invalidateQueries({ queryKey: ["caldav-status"] });
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ export function TeamSettings() {
|
|||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["tenant-members", tenantId],
|
queryKey: ["tenant-members", tenantId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<UserTenant[]>(`/api/tenants/${tenantId}/members`),
|
api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: (data: { email: string; role: string }) =>
|
mutationFn: (data: { email: string; role: string }) =>
|
||||||
api.post(`/api/tenants/${tenantId}/invite`, data),
|
api.post(`/tenants/${tenantId}/invite`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||||
setEmail("");
|
setEmail("");
|
||||||
@@ -52,7 +52,7 @@ export function TeamSettings() {
|
|||||||
|
|
||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (userId: string) =>
|
mutationFn: (userId: string) =>
|
||||||
api.delete(`/api/tenants/${tenantId}/members/${userId}`),
|
api.delete(`/tenants/${tenantId}/members/${userId}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||||
toast.success("Mitglied entfernt");
|
toast.success("Mitglied entfernt");
|
||||||
@@ -118,7 +118,7 @@ export function TeamSettings() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Members List */}
|
{/* Members List */}
|
||||||
{members && members.length > 0 ? (
|
{Array.isArray(members) && members.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||||
{members.map((member, i) => {
|
{members.map((member, i) => {
|
||||||
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
|
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
|
||||||
|
|||||||
@@ -176,6 +176,19 @@ export interface CalDAVSyncResponse {
|
|||||||
last_sync_at?: null;
|
last_sync_at?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 ApiError {
|
export interface ApiError {
|
||||||
error: string;
|
error: string;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -223,11 +236,47 @@ export interface UpcomingAppointment {
|
|||||||
case_title?: string;
|
case_title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecentActivity {
|
||||||
|
id: string;
|
||||||
|
event_type?: string;
|
||||||
|
title: string;
|
||||||
|
case_id: string;
|
||||||
|
case_number: string;
|
||||||
|
event_date?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
deadline_summary: DeadlineSummary;
|
deadline_summary: DeadlineSummary;
|
||||||
case_summary: CaseSummary;
|
case_summary: CaseSummary;
|
||||||
upcoming_deadlines: UpcomingDeadline[];
|
upcoming_deadlines: UpcomingDeadline[];
|
||||||
upcoming_appointments: UpcomingAppointment[];
|
upcoming_appointments: UpcomingAppointment[];
|
||||||
|
recent_activity?: RecentActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Activity
|
||||||
|
export interface RecentActivity {
|
||||||
|
id: string;
|
||||||
|
event_type?: string;
|
||||||
|
title: string;
|
||||||
|
case_id: string;
|
||||||
|
case_number: string;
|
||||||
|
event_date?: string;
|
||||||
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Extraction types
|
// AI Extraction types
|
||||||
|
|||||||
@@ -55,6 +55,6 @@ export async function middleware(request: NextRequest) {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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