commit 7f7da1596561e82770ac41b80e360a7347f79aa7 Author: Agent Zero Date: Sun May 31 20:36:42 2026 +0000 Initial commit: Rentman Clone - Phase 0-6 (T001-T023) Completed: - Phase 0: Project Setup (T001-T003) - Docker Compose, FastAPI skeleton, React SPA - Phase 1: Auth System (T004-T008) - DB models, JWT auth, RBAC middleware, user management - Phase 2: Contacts & Tags (T009-T011) - CRUD API + UI - Phase 3: Equipment Catalog (T012-T014) - Models, API, UI with barcode/QR - Phase 4: Crew Management (T015-T017) - Models, availability, UI - Phase 5: Vehicle Fleet (T018-T020) - Models, assignments, UI - Phase 6: Projects (T021-T023) - Project hierarchy models, CRUD API, list/detail UI diff --git a/.a0/AGENTS.md b/.a0/AGENTS.md new file mode 100644 index 0000000..ab9c583 --- /dev/null +++ b/.a0/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +## Project Overview +Brief description of the project. + +## Tech Stack +- Language: +- Framework: +- Database: +- Deployment: + +## Development Setup +Instructions to set up the development environment. + +## Build & Test +Commands to build and test the project. + +## Deployment +Basic deployment instructions. + +## Conventions +- Code style +- Commit message format +- Branch naming diff --git a/.a0/README.md b/.a0/README.md new file mode 100644 index 0000000..ef544f8 --- /dev/null +++ b/.a0/README.md @@ -0,0 +1,10 @@ +# Project Name + +## Description +What this project does. + +## Quick Start +How to get started quickly. + +## Documentation +Links to key documentation files. diff --git a/.a0/architecture.md b/.a0/architecture.md new file mode 100644 index 0000000..e338a9d --- /dev/null +++ b/.a0/architecture.md @@ -0,0 +1,273 @@ +# Architecture – Rentman.io Clone + +> **Version:** 1.0 +> **Date:** 2026-05-31 +> **Project:** Rentman.io-Nachbau +> **Architect:** Solution Architect (A0 Software Orchestrator) + +--- + +## Table of Contents + +1. [Tech-Stack](#tech-stack) +2. [System Components](#system-components) +3. [API Design](#api-design) +4. [Authentication & Authorization](#authentication--authorization) +5. [Multi-Tenant Strategy](#multi-tenant-strategy) +6. [Deployment Approach](#deployment-approach) +7. [Module Boundaries & Data Flow](#module-boundaries--data-flow) +8. [Risk Notes](#risk-notes) + +--- + +## Tech-Stack + +### Backend +- **Language:** Python 3.12+ +- **Framework:** FastAPI 0.115+ (async, high-performance, auto-docs) +- **ORM:** SQLAlchemy 2.0 with Alembic for migrations +- **Database:** SQLite for local development → PostgreSQL 16 for production +- **Caching:** Redis for session storage, rate limiting, and cache layer +- **Background Tasks:** Celery with Redis broker for async jobs (PDF generation, email) +- **File Storage:** Local filesystem for dev; S3-compatible (MinIO/AWS S3) for production + +### Frontend +- **Language:** TypeScript 5.x (strict mode) +- **Framework:** React 19 with React Router 7 (routing), TanStack Router also considered +- **State Management:** Zustand (global), React Query (server state) +- **UI Library:** Tailwind CSS 4 + Radix UI (accessible headless components) +- **Build Tool:** Vite 6 +- **Testing:** Vitest + React Testing Library +- **Mobile (PWA):** Service Worker with Workbox, Capacitor (for native camera/barcode) + +### Mobile App (PWA) +- Progressive Web App with offline support (Service Workers) +- Barcode scanning via Capacitor's Barcode Scanner plugin +- IndexedDB for local data store; sync with backend via REST API + +### DevOps & Deployment +- **Containerization:** Docker & Docker Compose +- **CI/CD:** GitHub Actions (build, test, lint, migrate, deploy) +- **Environment:** Local dev with Docker Compose; production on Coolify (CapRover alternative) +- **Monitoring:** Prometheus + Grafana (optional), Sentry for error tracking + +--- + +## System Components + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Rentman-Clone System │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ React SPA │ │ PWA Mobile │ │ External APIs│ │ Admin CLI │ │ +│ │ (Browser) │ │ (Capacitor) │ │ (Webhooks) │ │ (optional) │ │ +│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +│ └────────────────┴────────┬────────┴─────────────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ Nginx Reverse │ │ +│ │ Proxy (TLS) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ FastAPI App │ │ +│ │ (Backend API) │ │ +│ │ Port 8000 │ │ +│ └───┬───────┬───────┘ │ +│ │ │ │ +│ ┌────────────▼──┐ ┌──▼────────────┐ │ +│ │ Celery Worker │ │ PostgreSQL │ │ +│ │ (Background) │ │ / SQLite │ │ +│ │ + Redis Queue │ │ │ │ +│ └───────┬────────┘ └───────────────┘ │ +│ │ │ +│ ┌──────────▼──────────┐ │ +│ │ Object Storage │ │ +│ │ (MinIO / S3) │ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Component Descriptions +- **React SPA:** Client-side rendered app, communicates via JSON REST API, handles UI rendering. Built with Vite for fast builds. +- **PWA Mobile App:** Same React codebase but served with Service Worker for offline capability; uses Capacitor for camera/barcode scanning. Can be deployed as TWA (Trusted Web Activity) on Android or via iOS WebAPKs. +- **External APIs (Webhooks):** Endpoints for third-party integrations (calendars, external CRM/ERP). Can also consume webhooks. +- **Nginx Reverse Proxy:** SSL termination, static file serving (for SPA), rate limiting, and optional load balancing. +- **FastAPI Backend:** RESTful API with JWT auth, RBAC, openapi schema. Implements all business logic. Uses SQLAlchemy async sessions. +- **Celery Worker:** Offloads long-running tasks: PDF generation, mass exports, email sending, webhook dispatching. Uses Redis as message broker. +- **Redis:** Used for Celery broker, also for caching query results (Redis Cache), session storage, and rate limiting counters. +- **PostgreSQL / SQLite:** SQLite for local dev (single file), PostgreSQL for production. Both accessed through same SQLAlchemy models, migrations applied via Alembic. +- **Object Storage (MinIO/S3):** Stores uploaded documents, equipment images, generated PDFs/CSVs. Provides signed URLs for download. + +--- + +## API Design + +### General Principles +- RESTful JSON API. +- Base URL: `/api/v1/` (versioned for future updates). +- Authentication: Bearer JWT token in `Authorization` header. +- Standard HTTP status codes: 200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable, 500 Internal Server Error. +- Pagination: all list endpoints return JSON with `items`, `total`, `page`, `size`. +- Filtering: query parameters `?status=X&project_type=Y&sort=created_at&order=asc`. +- Nested resources for sub-entities (e.g., `/projects/{project_id}/subprojects`). +- OpenAPI (Swagger) auto-generated at `/docs` and `/redoc`. + +### Endpoint Overview + +**Auth:** +- `POST /api/v1/auth/login` – login (email, password) → JWT token + refresh token +- `POST /api/v1/auth/refresh` – refresh token +- `POST /api/v1/auth/register` – self-service registration (tenant creation) + +**Users & Roles:** +- `GET/POST /api/v1/users` – list/create users (admin) +- `GET/PUT/DELETE /api/v1/users/{id}` – user CRUD +- `GET/PUT /api/v1/roles` – roles management + +**Projects:** +- `GET/POST /api/v1/projects` – list/create +- `GET/PUT/DELETE /api/v1/projects/{id}` – project CRUD +- `GET /api/v1/projects/{id}/subprojects` – subprojects +- `POST /api/v1/projects/{id}/subprojects` – create subproject +- `GET/PUT/DELETE /api/v1/projects/{project_id}/subprojects/{id}` – subproject CRUD +- Project function groups: `/projects/{project_id}/functiongroups` (nested) +- Project functions: `/projects/{project_id}/functiongroups/{group_id}/functions` +- Equipment groups: `/projects/{project_id}/equipmentgroups` +- Equipment items within group: `/projects/{project_id}/equipmentgroups/{group_id}/equipment` +- Crew assignments: `/projects/{project_id}/crew` (via functions) +- Vehicle assignments: `/projects/{project_id}/vehicles` +- Quotes: `/projects/{project_id}/quotes` +- Invoices: `/projects/{project_id}/invoices` + +**Equipment Catalog:** +- `GET/POST /api/v1/equipment` – list/create +- `GET/PUT/DELETE /api/v1/equipment/{id}` – CRUD +- `GET /api/v1/equipment/{id}/availability` – availability timeline + +**Crew Catalog:** +- `GET/POST /api/v1/crew` – list/create +- `GET/PUT/DELETE /api/v1/crew/{id}` – CRUD +- `GET /api/v1/crew/{id}/availability` – availability + +**Vehicles:** +- `GET/POST /api/v1/vehicles` – list/create +- `GET/PUT/DELETE /api/v1/vehicles/{id}` – CRUD + +**Contacts:** +- `GET/POST /api/v1/contacts` – list/create +- `GET/PUT/DELETE /api/v1/contacts/{id}` – CRUD + +**Project Requests:** +- `GET/POST /api/v1/requests` – public/private +- `GET/PUT /api/v1/requests/{id}` – read/update +- `POST /api/v1/requests/{id}/convert` – convert to project + +**Documents:** +- `GET/POST /api/v1/documents` – list/upload +- `GET/DELETE /api/v1/documents/{id}` – download/delete +- Templates: `/api/v1/templates` (admin) + +**Reports:** +- `GET /api/v1/reports/finance` – financial KPIs +- `GET /api/v1/reports/equipment-utilization` – equipment utilization +- `GET /api/v1/reports/project-stats` – project statistics + +**Export:** +- `POST /api/v1/export/{module}` – asynchronous export to CSV/Excel + +**Webhooks:** +- `GET/POST /api/v1/webhooks` – manage webhook subscriptions +- `POST /api/v1/webhooks/{id}/trigger` – test trigger + +**Admin:** +- `GET /api/v1/audit-log` – audit log entries + +--- + +## Authentication & Authorization + +### Authentication +- JWT access tokens (short-lived, 30 minutes) + refresh tokens (24 hours, stored in httpOnly cookie or secure storage). +- Password hashing with bcrypt. +- Optional OAuth2/OpenID Connect in V2 (not MVP). +- Self-service registration creates a new tenant account (company) and admin user. + +### Authorization – RBAC +- Role-based access control with four fixed roles: Admin, Project Manager, Warehouse Operator, Freelancer. +- Custom roles can be defined later; permissions are defined as granular rights: + - Module-level: `projects:read`, `projects:write`, `projects:delete`, `equipment:read`, etc. + - Object-level: user can only access projects they are assigned to (or all for admin). +- Roles are stored as list of permissions in `Role` model. +- Middleware checks token claims and resolves permissions; `Depends()` function in FastAPI routes. + +### Multi-Tenant Strategy +- **Shared Database with Tenant ID:** Each table contains a `tenant_id` (UUID) foreign key to `Account` table. +- Row-Level Security: SQLAlchemy queries automatically filter by the tenant associated with the authenticated user's `account_id` (from JWT). +- Isolation: one account cannot see another's data. +- Onboarding: registration creates a new `Account` and an initial admin user. + +--- + +## Deployment Approach + +### Local Development +- Docker Compose with services: backend (FastAPI hot-reload), frontend (Vite dev server), PostgreSQL, Redis, MinIO. +- SQLite as default for quick dev (just set `DATABASE_URL=sqlite:///./rentman.db`), but Docker-Compose uses PostgreSQL. +- No volumes; data in container. + +### Production (Coolify) +- Docker Compose as deployable artifact. +- Coolify handles SSL, domain routing, environment variables. +- CI/CD: GitHub Actions build test, then push image to registry; Coolify webhook triggers redeploy. +- Database backups via Coolify's built-in PostgreSQL backup feature. +- File storage: S3 bucket (MinIO or AWS S3). + +### Scalability +- Backend stateless → horizontal scaling with multiple API instances behind load balancer. +- Celery workers can be scaled independently. +- PostgreSQL read replicas for heavy reporting. + +--- + +## Module Boundaries & Data Flow + +``` +React SPA <──> FastAPI <──> SQLAlchemy <──> Database + <──> Celery (Redis) <──> Email/PDF/S3 +``` + +**Modules (in backend):** +- `auth` – login, register, refresh +- `users` – user management, roles +- `projects` – project CRUD, subprojects, function groups, functions +- `equipment` – equipment catalog, categories, stock locations +- `crew` – crew catalog, availability +- `vehicles` – vehicles, assignments +- `contacts` – contacts (companies/persons) +- `project_requests` – public request handling +- `quotes_invoices` – quotes, invoices, templates, PDF generation +- `documents` – file uploads, downloads +- `reports` – analytics endpoints +- `webhooks` – manage subscriptions, dispatch + +**Frontend (React):** +- `pages/` – top-level routes +- `components/` – reusable UI (forms, tables, modals) +- `hooks/` – custom hooks for API calls +- `stores/` – Zustand stores (global state) +- `services/` – API client (axios/fetch wrappers) +- `i18n/` – translations (DE, EN) + +--- + +## Risk Notes +- **SQLite vs PostgreSQL discrepancies:** SQLite doesn't support some SQL features (e.g., `ALTER COLUMN`). Use Alembic to handle dialect differences; tests must run on PostgreSQL. +- **Offline Sync Conflicts:** Offline-first with Last-Write-Wins may cause data loss if two users edit same entity offline. For MVP, limited to equipment status and packlist checkmarks; acceptable. +- **Performance with large rental sets:** 500k equipment items → need indexing on tenant_id, status, and category. Reporting queries may be slow; consider materialized views. +- **Security:** JWT secret must be managed; never hardcoded. Use environment variable. Rate limiting prevents brute-force attacks. +- **Multi-Tenant Data Isolation:** Must ensure all queries include tenant filter. Implement a SQLAlchemy query filter on session creation to avoid accidental leaks. +- **PWA Barcode Scanning:** Capacitor camera plugin may require additional permissions; need to handle permission prompts gracefully. +- **Complexity of Project Hierarchy:** Project → Subproject → FunctionGroup → Function → (Crew/Equipment) is deep; ensure API design is consistent and documentation clear. diff --git a/.a0/current_status.md b/.a0/current_status.md new file mode 100644 index 0000000..18b227a --- /dev/null +++ b/.a0/current_status.md @@ -0,0 +1,20 @@ +# Current Status + +## Phase: 6 (Projects) – T023 COMPLETE + +## Last Completed +- T023: Project list and detail UI + - Created ProjectForm.tsx (form component with all project fields) + - Created Projects.tsx (list page with search and status filter) + - Created ProjectNew.tsx (new project form page) + - Created ProjectEdit.tsx (edit project form page) + - Created ProjectDetail.tsx (detail page with SubProjects, FunctionGroups, Functions display) + - Updated App.tsx with routes: /projects, /projects/new, /projects/:projectId, /projects/:projectId/edit + +## Next Tasks (Phase 6) +- T024: Project function group and function editor UI +- T025: ProjectEquipment and ProjectCrew +- T026+: Remaining project features + +## Blockers +- None diff --git a/.a0/decisions.md b/.a0/decisions.md new file mode 100644 index 0000000..9dde35d --- /dev/null +++ b/.a0/decisions.md @@ -0,0 +1,160 @@ +# Architecture Decision Records (ADR) – Rentman.io Clone + +> **Version:** 1.0 +> **Date:** 2026-05-31 +> **Project:** Rentman.io-Nachbau +> **Architect:** Solution Architect (A0 Software Orchestrator) + +--- + +## ADR-001: Backend-Framework + +**Status:** Accepted + +**Decision:** FastAPI (Python) + +**Alternatives considered:** +- **NestJS (Node.js/TypeScript):** Same language for frontend/backend, but Python has stronger ecosystem for data processing, PDF generation, and ORM maturity (SQLAlchemy > TypeORM in stability). Higher learning curve for team. +- **Laravel (PHP):** Mature, batteries-included, but monolithic by nature; API-centric design is less natural. +- **Flask:** Too minimal; FastAPI provides async support, auto-generated OpenAPI docs, Pydantic validation for free. + +**Rationale:** FastAPI combines high performance (async) with automatic documentation and validation that reduces manual work. Python's ecosystem for reports/exports (openpyxl, WeasyPrint) outweighs the language mismatch with frontend. + +--- + +## ADR-002: Frontend-Strategie + +**Status:** Accepted + +**Decision:** React SPA with Vite + TypeScript, PWA via Service Workers + Capacitor + +**Alternatives considered:** +- **Next.js (SSR):** Adds complexity for an internal business app where SEO is irrelevant and CSR is sufficient. SSR would require Node.js runtime in production. +- **Vue/Nuxt:** Simpler than React, but smaller ecosystem for complex interactive UI components (Gantt charts, drag-and-drop scheduling). +- **SvelteKit:** Promising but still too new; fewer UI component libraries. + +**Rationale:** React has the largest ecosystem for business UIs (React Query, Zustand, Radix, TanStack Table). SPA (CSR) is sufficient because the app is used behind authentication. PWA support provides mobile app without separate native codebase. Capacitor grants access to barcode scanning via native plugin. + +--- + +## ADR-003: Multi-Tenant-Strategie + +**Status:** Accepted + +**Decision:** Shared Database with `account_id` (tenant_id) column on every table + +**Alternatives considered:** +- **Database-per-tenant:** Strongest isolation, but high operational overhead (managing hundreds of databases), harder cross-tenant analytics, expensive cloud costs. +- **Schema-per-tenant:** Middle ground, but ORM support is complex (dynamic schema switching), and PostgreSQL schema limits are lower than tables. + +**Rationale:** Shared DB with tenant_id is simplest to implement and operate for an MVP. Row-level isolation is enforced at query level (SQLAlchemy session filter). For future scaling, read replicas or partition by tenant can be added. + +--- + +## ADR-004: Datenbank-Wahl (Dev vs Production) + +**Status:** Accepted + +**Decision:** SQLite for local development, PostgreSQL for testing and production + +**Alternatives considered:** +- **SQLite only:** Simple, but lacks concurrent write performance for 50+ users, no JSONB indexing, limited migration capabilities (ALTER COLUMN). +- **PostgreSQL only:** Requires every developer to run PostgreSQL locally or via Docker. Adds friction for quick tests. + +**Rationale:** SQLite allows "clone and run" developer experience. PostgreSQL is required for realistic testing and production scale. SQLAlchemy + Alembic handle dialect differences. All CI tests run on PostgreSQL to catch incompatibilities early. + +--- + +## ADR-005: Projekt-Hierarchie-Modellierung + +**Status:** Accepted + +**Decision:** Project → Subproject → FunctionGroup → Function (→ Crew/Equipment) + +**Alternatives considered:** +- **Flat project with tags:** Simpler, but fails to represent hierarchical structure of real events (e.g., a festival with multiple stages, each with sound/light/video functions). +- **Nested Set (MPTT):** Complex for tree operations, harder to query for availability (time-based conflicts across subtrees). + +**Rationale:** Rentman's original model is hierarchical because real AV projects are hierarchical. The 4-level depth with optional Subprojects and FunctionGroups gives maximum flexibility without over-complexity. Each level can have its own time periods, enabling partial rental (e.g., sound equipment only for 2 days within a 7-day project). + +--- + +## ADR-006: Preisberechnung-Strategie + +**Status:** Accepted + +**Decision:** Calculated fields stored on Project (updated at query time or via trigger) + +**Alternatives considered:** +- **Real-time calculation on every read:** Accurate but slow for list views showing 50+ projects. +- **Materialized view:** Efficient but complex to maintain (must refresh after any change to sub-items). + +**Rationale:** Store calculated `price` and `cost` on the `Project` row, updated whenever sub-items (equipment, crew functions) are modified. Application-layer recalculation after each CUD operation on related entities. Cache in Redis for dashboard aggregations. + +--- + +## ADR-007: PDF/Template-Engine + +**Status:** Accepted + +**Decision:** HTML-based templates with WeasyPrint rendering + +**Alternatives considered:** +- **ReportLab:** Python-native, more control over layout, but template creation is code-only, requiring developer for each template. +- **LaTeX:** Professional quality, but heavy dependency; not UX-friendly for admin users to edit templates. +- **WYSIWYG (e.g., GrapesJS):** Full drag-and-drop, but generates messy HTML, hard to placeholders, and adds significant frontend complexity. + +**Rationale:** HTML templates with placeholder syntax (`{{project_name}}`, `{{items_table}}`) offer the best balance: designers can create templates with CSS, non-technical admins can modify content, and WeasyPrint converts to high-quality PDF. This mirrors Rentman's approach (HTML-like template with placeholders). + +--- + +## ADR-008: Offline-Sync-Strategie + +**Status:** Accepted + +**Decision:** Last-Write-Wins (LWW) with conflict warnings + +**Alternatives considered:** +- **CRDT (Conflict-free Replicated Data Types):** No conflicts, but requires specialized data structures and complex merge logic. +- **OT (Operational Transformation):** Similar to Google Docs, overkill for checkmarks and status updates. + +**Rationale:** Offline capabilities are needed for equipment packing (checkboxes, status changes) where conflicts are rare (usually one person per project scans items). LWW is simple, implementable with IndexedDB timestamps, and sufficient. When a conflict is detected (same item modified offline by two users), show a warning and let the user decide. + +--- + +## ADR-009: API-Versionierung und -Dokumentation + +**Status:** Accepted + +**Decision:** URL-based versioning (`/api/v1/...`) with OpenAPI auto-generation + +**Alternatives considered:** +- **Header-based versioning (`Accept: application/vnd.rentman.v1+json`):** Cleaner, but harder to test and document. +- **No versioning:** Faster now, but breaking changes inevitable. + +**Rationale:** URL versioning is simplest for client developers (visible, testable via browser/curl). FastAPI's automatic OpenAPI generation ensures docs are always in sync with code. + +--- + +## ADR-010: Deployment-Plattform + +**Status:** Accepted + +**Decision:** Docker Compose as deployable unit, Coolify for production management + +**Alternatives considered:** +- **Kubernetes (K8s):** Overkill for a single-server deployment; adds complexity without immediate benefit. +- **Manual VPS setup:** Error-prone, hard to reproduce, no rollback. +- **PaaS (Heroku, Render):** Expensive at scale, less control over infrastructure. + +**Rationale:** Docker Compose works identically on dev machines and production. Coolify provides a web-based management layer similar to CapRover/Porter without the Kubernetes complexity. Easy SSL, environment management, database backups, and app restarts. + +--- + +## Open Decisions (Not Yet Resolved) + +1. **Authentication Provider (V2):** Should OAuth2/OIDC (Google, Azure AD) be added in MVP or V2? Current decision: Basic JWT only for MVP; OIDC in V2. +2. **Barcode type:** QR-Code (primary) + Code128 (optional). Label size: 50×25mm standard. Label printer integration not in scope. +3. **Email provider:** SMTP (default) with optional SendGrid/Mailgun API. Transactional emails only. +4. **Datev or full accounting export:** Not in scope for MVP. +5. **Pricing model:** Open Source (MIT) core with optional SaaS hosting. Decision to be made by product owner. diff --git a/.a0/design.md b/.a0/design.md new file mode 100644 index 0000000..37a4157 --- /dev/null +++ b/.a0/design.md @@ -0,0 +1,730 @@ +# Database Design – Rentman.io Clone + +> **Version:** 1.0 +> **Date:** 2026-05-31 +> **Project:** Rentman.io-Nachbau +> **Architect:** Solution Architect (A0 Software Orchestrator) + +--- + +## 1. Entity-Relationship Diagram (Textual) + +``` +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Account │ │ User │ │ Role │ +│ (tenant) │ │ │ │ │ +│ id (PK) │1───*│ id (PK) │*───*│ id (PK) │ +│ name │ │ account_id (FK) │ │ name │ +│ created_at │ │ email │ │ permissions (JSON│ +└──────────────────┘ │ full_name │ └──────────────────┘ + │ password_hash │ + │ role_id (FK) │ + └────────────────────┘ + +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Tag │ │ Contact │ │ Document │ +│ id (PK) │*───*│ id (PK) │1───*│ id (PK) │ +│ name │ │ account_id (FK) │ │ account_id (FK) │ +│ color │ │ type │ │ name │ +└──────────────────┘ │ company_name │ │ file_path │ + │ first_name │ │ mime_type │ + │ last_name │ │ size_bytes │ + │ email, phone, mobile│ │ project_id (FK) │ + │ street, number, │ │ uploaded_by (FK) │ + │ postalcode, city, │ │ created_at │ + │ country, tax_number│ └──────────────────┘ + │ note │ + └───────────────────┘ + +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Project │ │ Subproject │ │ ProjectFunctionGroup│ +│ id (PK) │1───*│ id (PK) │1───*│ id (PK) │ +│ account_id (FK) │ │ project_id (FK) │ │ project_id (FK) │ +│ name │ │ name │ │ subproject_id (FK)│ +│ reference │ │ custom_fields (JSON)│ │ name │ +│ number │ └──────────────────┘ │ usageperiod_start/end│ +│ customer_id (FK) │ │ planperiod_start/end│ +│ contact_person_id │ │ remark │ +│ account_manager_id│ └──────┬───────────┘ +│ project_type │ │ +│ planperiod_start/end│ │ +│ usageperiod_start/end│ │ +│ status │ ┌──────▼──────────┐ +│ location_* │ │ ProjectFunction │ +│ remark │ │ id (PK) │ +│ price, cost │ │ project_id (FK) │ +│ created_at, updated_at│ │ subproject_id (FK)│ +│ archived │ │ group_id (FK) │ +└──────┬────────────┘ │ type (enum) │ + │ │ amount, usg/plan│ + │ │ cost_rate, price_rate│ + │ │ remark │ + │ └──────┬──────────┘ + │ │ + │ ┌──────▼──────────┐ + ├───────────────────────────────────────────►│ ProjectCrew │ + │ │ id (PK) │ + │ ProjectEquipmentGroup │ project_id (FK) │ + ├───────────────────────────────────────────►│ function_id (FK) │ + │ │ crew_id (FK) │ + │ ProjectEquipment (within group) │ start, end │ + │ └──────────────────┘ + │ + ├──► Quote + │ (id, project_id, number, status, valid_until, + │ template, content_html, total_net, total_gross, created_at) + │ + ├──► Invoice + │ (id, project_id, number, status, due_date, + │ total_net, total_gross, created_at) + │ + ├──► ProjectRequest (id, name, contact fields, location, periods, status, remark) + │ │ + │ └──► ProjectRequestEquipment (id, request_id, name, quantity, unit_price, linked_equipment_id) + │ + └──► VehicleAssignment + (id, vehicle_id, project_id, start, end, driver_id) + +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Equipment │ │ Crew │ │ Vehicle │ +│ id (PK) │ │ id (PK) │ │ id (PK) │ +│ account_id (FK) │ │ account_id (FK) │ │ account_id (FK) │ +│ name │ │ first_name │ │ name │ +│ category │ │ last_name │ │ license_plate │ +│ serial_number │ │ email, phone │ │ type │ +│ barcode, qr_code │ │ type (internal/ │ │ payload_kg │ +│ stock_location_id │ │ freelancer) │ │ volume_m3 │ +│ status │ │ hourly_rate │ │ is_active │ +│ purchase_price │ │ daily_rate │ └──────────────────┘ +│ current_value │ │ skills (JSON) │ +│ weight_kg, dims, │ │ note │ +│ power_watt, notes │ │ user_id (FK) │ +│ custom_fields, img│ │ is_active │ +└──────────────────┘ └────────┬──────────┘ + │ +┌──────────────────┐ ┌────────▼──────────┐ +│ EquipmentGroup │ │ CrewAvailability │ +│ (Bundle) │ │ id (PK) │ +│ id (PK) │ │ crew_id (FK) │ +│ account_id (FK) │ │ start, end │ +│ name │ │ type (avail/unavail│ +│ items (JSON/M2M) │ │ reason │ +└──────────────────┘ └───────────────────┘ + +┌───────────────────┐ +│ StockLocation │ +│ id (PK) │ +│ account_id (FK) │ +│ name │ +│ address │ +│ is_default │ +└───────────────────┘ + +┌───────────────────┐ +│ AuditLog │ +│ id (PK) │ +│ account_id (FK) │ +│ user_id (FK) │ +│ action │ +│ entity_type │ +│ entity_id │ +│ details (JSON) │ +│ timestamp │ +└───────────────────┘ +``` + +## 2. Table Definitions + +### 2.1 Account +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PRIMARY KEY, DEFAULT gen_random_uuid() (on PG) | +| name | VARCHAR(255) | NOT NULL | +| created_at | TIMESTAMP WITH TIME ZONE | NOT NULL, DEFAULT now() | + +**Indexes:** None extra beyond PK. + +--- + +### 2.2 User +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| email | VARCHAR(255) | NOT NULL, UNIQUE | +| full_name | VARCHAR(255) | NOT NULL | +| password_hash | VARCHAR(255) | NOT NULL | +| role_id | UUID | NOT NULL, FK → Role(id) | +| is_active | BOOLEAN | NOT NULL, DEFAULT TRUE | +| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | +| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | + +**Indexes:** +- `ix_user_account_id` on `account_id` +- `ix_user_email` UNIQUE on `email` + +--- + +### 2.3 Role +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(100) | NOT NULL | +| permissions | JSONB | NOT NULL (array of strings like `["projects:read", "projects:write"]`) | + +**Indexes:** +- `ix_role_account_id` on `account_id` + +--- + +### 2.4 Tag +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(100) | NOT NULL | +| color | VARCHAR(7) | NULLABLE (hex color) | + +**Indexes:** +- `ix_tag_account_id` on `account_id` + +**Relationships:** +- Many-to-Many with Equipment, Project, Crew (via join tables: `equipment_tags`, `project_tags`, `crew_tags`). + +--- + +### 2.5 Contact +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| type | ENUM('company','person') | NOT NULL | +| company_name | VARCHAR(255) | NULLABLE | +| first_name | VARCHAR(100) | NULLABLE | +| last_name | VARCHAR(100) | NULLABLE | +| email | VARCHAR(255) | NULLABLE | +| phone | VARCHAR(50) | NULLABLE | +| mobile | VARCHAR(50) | NULLABLE | +| street | VARCHAR(255) | NULLABLE | +| number | VARCHAR(20) | NULLABLE | +| postalcode | VARCHAR(20) | NULLABLE | +| city | VARCHAR(100) | NULLABLE | +| country | VARCHAR(100) | NULLABLE | +| tax_number | VARCHAR(50) | NULLABLE | +| note | TEXT | NULLABLE | + +**Indexes:** +- `ix_contact_account_id` on `account_id` +- `ix_contact_type` on `type` + +--- + +### 2.6 Document +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(255) | NOT NULL | +| original_filename | VARCHAR(255) | NOT NULL | +| file_path | VARCHAR(1024) | NOT NULL (relative to storage root) | +| mime_type | VARCHAR(127) | NOT NULL | +| size_bytes | INTEGER | NOT NULL | +| project_id | UUID | NULLABLE, FK → Project(id) | +| uploaded_by | UUID | NOT NULL, FK → User(id) | +| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | + +**Indexes:** +- `ix_document_account_id` on `account_id` +- `ix_document_project_id` on `project_id` + +--- + +### 2.7 Project +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(255) | NOT NULL | +| reference | VARCHAR(50) | NULLABLE | +| number | VARCHAR(50) | NOT NULL, automatically generated (prefix + sequential) | +| displayname | VARCHAR(512) | GENERATED (name + number) | +| customer_id | UUID | NULLABLE, FK → Contact(id) | +| contact_person_id | UUID | NULLABLE, FK → Contact(id) | +| account_manager_id | UUID | NULLABLE, FK → User(id) | +| project_type | ENUM('rental','service','sale') | NULLABLE | +| planperiod_start | TIMESTAMPTZ | NOT NULL | +| planperiod_end | TIMESTAMPTZ | NOT NULL | +| usageperiod_start | TIMESTAMPTZ | NOT NULL | +| usageperiod_end | TIMESTAMPTZ | NOT NULL | +| status | ENUM('request','planned','confirmed','active','completed','cancelled') | DEFAULT 'planned' | +| location_name | VARCHAR(255) | NULLABLE | +| location_street | VARCHAR(255) | NULLABLE | +| location_city | VARCHAR(100) | NULLABLE | +| location_postalcode | VARCHAR(20) | NULLABLE | +| location_country | VARCHAR(100) | NULLABLE | +| remark | TEXT | NULLABLE | +| price | DECIMAL(12,2) | NULLABLE (calculated field, updated by triggers) | +| cost | DECIMAL(12,2) | NULLABLE | +| created_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | +| updated_at | TIMESTAMPTZ | NOT NULL, DEFAULT now() | +| archived | BOOLEAN | NOT NULL, DEFAULT FALSE | + +**Indexes:** +- `ix_project_account_id` on `account_id` +- `ix_project_status` on `status` +- `ix_project_planperiod` on `planperiod_start`, `planperiod_end` (for availability queries) +- `ix_project_customer_id` on `customer_id` + +--- + +### 2.8 Subproject +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) ON DELETE CASCADE | +| name | VARCHAR(255) | NOT NULL | +| custom_fields | JSONB | NULLABLE | + +**Indexes:** +- `ix_subproject_project_id` on `project_id` + +--- + +### 2.9 ProjectFunctionGroup +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) ON DELETE CASCADE | +| subproject_id | UUID | NULLABLE, FK → Subproject(id) | +| name | VARCHAR(255) | NOT NULL | +| usageperiod_start | TIMESTAMPTZ | NULLABLE | +| usageperiod_end | TIMESTAMPTZ | NULLABLE | +| planperiod_start | TIMESTAMPTZ | NULLABLE | +| planperiod_end | TIMESTAMPTZ | NULLABLE | +| remark | TEXT | NULLABLE | + +**Indexes:** +- `ix_pfg_project_id` on `project_id` + +--- + +### 2.10 ProjectFunction +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) ON DELETE CASCADE | +| subproject_id | UUID | NULLABLE, FK → Subproject(id) | +| group_id | UUID | NULLABLE, FK → ProjectFunctionGroup(id) | +| name | VARCHAR(255) | NOT NULL | +| type | ENUM('crew_function','equipment_function','transport_function') | NOT NULL | +| amount | INTEGER | DEFAULT 1 | +| usageperiod_start | TIMESTAMPTZ | NULLABLE | +| usageperiod_end | TIMESTAMPTZ | NULLABLE | +| planperiod_start | TIMESTAMPTZ | NULLABLE | +| planperiod_end | TIMESTAMPTZ | NULLABLE | +| cost_rate | DECIMAL(10,2) | NULLABLE | +| price_rate | DECIMAL(10,2) | NULLABLE | +| remark | TEXT | NULLABLE | + +**Indexes:** +- `ix_projectfunction_project_id` on `project_id` +- `ix_projectfunction_group_id` on `group_id` + +--- + +### 2.11 ProjectCrew +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) | +| function_id | UUID | NOT NULL, FK → ProjectFunction(id) ON DELETE CASCADE | +| crew_id | UUID | NOT NULL, FK → Crew(id) | +| start | TIMESTAMPTZ | NULLABLE (can default to project usage period) | +| end | TIMESTAMPTZ | NULLABLE | + +**Indexes:** +- `ix_projectcrew_project_id` on `project_id` +- `ix_projectcrew_function_id` on `function_id` +- `ix_projectcrew_crew_id` on `crew_id` + +--- + +### 2.12 ProjectEquipmentGroup +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) ON DELETE CASCADE | +| subproject_id | UUID | NULLABLE, FK → Subproject(id) | +| name | VARCHAR(255) | NOT NULL | +| usageperiod_start | TIMESTAMPTZ | NULLABLE | +| usageperiod_end | TIMESTAMPTZ | NULLABLE | +| planperiod_start | TIMESTAMPTZ | NULLABLE | +| planperiod_end | TIMESTAMPTZ | NULLABLE | +| in_price_calculation | BOOLEAN | DEFAULT TRUE | +| remark | TEXT | NULLABLE | + +**Indexes:** +- `ix_peg_project_id` on `project_id` + +--- + +### 2.13 ProjectEquipment +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_id | UUID | NOT NULL, FK → Project(id) | +| equipment_group_id | UUID | NOT NULL, FK → ProjectEquipmentGroup(id) ON DELETE CASCADE | +| equipment_id | UUID | NULLABLE, FK → Equipment(id) | +| name | VARCHAR(255) | NOT NULL (can be free-text if equipment_id is NULL) | +| quantity | INTEGER | NOT NULL, DEFAULT 1 | +| quantity_total | INTEGER | NULLABLE (available inventory count) | +| unit_price | DECIMAL(10,2) | NULLABLE | +| discount | DECIMAL(5,2) | NULLABLE (percentage) | +| external_remark | TEXT | NULLABLE | +| internal_remark | TEXT | NULLABLE | + +**Indexes:** +- `ix_projectequipment_group_id` on `equipment_group_id` +- `ix_projectequipment_equipment_id` on `equipment_id` + +--- + +### 2.14 Equipment (Catalog) +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(255) | NOT NULL | +| category | VARCHAR(100) | NULLABLE | +| brand | VARCHAR(100) | NULLABLE | +| model | VARCHAR(100) | NULLABLE | +| serial_number | VARCHAR(100) | NULLABLE | +| barcode | VARCHAR(100) | NULLABLE, UNIQUE | +| qr_code | VARCHAR(100) | NULLABLE, UNIQUE | +| stock_location_id | UUID | NULLABLE, FK → StockLocation(id) | +| status | ENUM('available','maintenance','defect','retired') | DEFAULT 'available' | +| purchase_price | DECIMAL(10,2) | NULLABLE | +| current_value | DECIMAL(10,2) | NULLABLE | +| weight_kg | DECIMAL(10,2) | NULLABLE | +| dimensions_cm | VARCHAR(50) | NULLABLE | +| power_watt | INTEGER | NULLABLE | +| custom_fields | JSONB | NULLABLE | +| notes | TEXT | NULLABLE | +| image_url | VARCHAR(2048) | NULLABLE | + +**Indexes:** +- `ix_equipment_account_id` on `account_id` +- `ix_equipment_category` on `category` +- `ix_equipment_status` on `status` +- `ix_equipment_barcode` UNIQUE on `barcode` +- `ix_equipment_qrcode` UNIQUE on `qr_code` + +--- + +### 2.15 StockLocation +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(100) | NOT NULL | +| address | VARCHAR(255) | NULLABLE | +| is_default | BOOLEAN | DEFAULT FALSE | + +**Indexes:** +- `ix_stocklocation_account_id` on `account_id` + +--- + +### 2.16 Crew +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| first_name | VARCHAR(100) | NOT NULL | +| last_name | VARCHAR(100) | NOT NULL | +| email | VARCHAR(255) | NULLABLE | +| phone | VARCHAR(50) | NULLABLE | +| type | ENUM('internal','freelancer') | NOT NULL | +| user_id | UUID | NULLABLE, FK → User(id) (if they have login) | +| hourly_rate | DECIMAL(10,2) | NULLABLE | +| daily_rate | DECIMAL(10,2) | NULLABLE | +| skills | JSONB | NULLABLE (array of strings) | +| is_active | BOOLEAN | DEFAULT TRUE | +| note | TEXT | NULLABLE | + +**Indexes:** +- `ix_crew_account_id` on `account_id` +- `ix_crew_type` on `type` +- `ix_crew_is_active` on `is_active` + +--- + +### 2.17 CrewAvailability +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| crew_id | UUID | NOT NULL, FK → Crew(id) ON DELETE CASCADE | +| start | TIMESTAMPTZ | NOT NULL | +| end | TIMESTAMPTZ | NOT NULL | +| type | ENUM('available','unavailable','tentative') | DEFAULT 'unavailable' | +| reason | VARCHAR(255) | NULLABLE | + +**Indexes:** +- `ix_crewavailability_crew_id` on `crew_id` +- `ix_crewavailability_period` on `start`, `end` (for overlap checks) + +--- + +### 2.18 Vehicle +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(100) | NOT NULL | +| license_plate | VARCHAR(20) | NULLABLE | +| type | VARCHAR(50) | NULLABLE | +| payload_kg | INTEGER | NULLABLE | +| volume_m3 | DECIMAL(10,2) | NULLABLE | +| is_active | BOOLEAN | DEFAULT TRUE | + +**Indexes:** +- `ix_vehicle_account_id` on `account_id` + +--- + +### 2.19 VehicleAssignment +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| vehicle_id | UUID | NOT NULL, FK → Vehicle(id) | +| project_id | UUID | NOT NULL, FK → Project(id) | +| start | TIMESTAMPTZ | NOT NULL | +| end | TIMESTAMPTZ | NOT NULL | +| driver_id | UUID | NULLABLE, FK → Crew(id) | + +**Indexes:** +- `ix_vehicleassignment_vehicle_id` on `vehicle_id` +- `ix_vehicleassignment_project_id` on `project_id` +- `ix_vehicleassignment_period` on `start`, `end` + +--- + +### 2.20 ProjectRequest +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(255) | NOT NULL | +| contact_name | VARCHAR(255) | NULLABLE | +| contact_person_first_name | VARCHAR(100) | NULLABLE | +| contact_person_lastname | VARCHAR(100) | NULLABLE | +| contact_person_email | VARCHAR(255) | NULLABLE | +| location_name | VARCHAR(255) | NULLABLE | +| location_mailing_street | VARCHAR(255) | NULLABLE | +| location_mailing_number | VARCHAR(20) | NULLABLE | +| location_mailing_postalcode | VARCHAR(20) | NULLABLE | +| location_mailing_city | VARCHAR(100) | NULLABLE | +| location_mailing_country | VARCHAR(100) | NULLABLE | +| planperiod_start | TIMESTAMPTZ | NULLABLE | +| planperiod_end | TIMESTAMPTZ | NULLABLE | +| usageperiod_start | TIMESTAMPTZ | NULLABLE | +| usageperiod_end | TIMESTAMPTZ | NULLABLE | +| status | ENUM('new','processing','converted','rejected') | DEFAULT 'new' | +| remark | TEXT | NULLABLE | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +**Indexes:** +- `ix_projectrequest_account_id` on `account_id` +- `ix_projectrequest_status` on `status` + +--- + +### 2.21 ProjectRequestEquipment +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| project_request_id | UUID | NOT NULL, FK → ProjectRequest(id) ON DELETE CASCADE | +| name | VARCHAR(255) | NOT NULL | +| quantity | INTEGER | NOT NULL | +| quantity_total | INTEGER | NULLABLE | +| unit_price | DECIMAL(10,2) | NULLABLE | +| linked_equipment_id | UUID | NULLABLE, FK → Equipment(id) | + +**Indexes:** +- `ix_prequestequip_request_id` on `project_request_id` + +--- + +### 2.22 Quote +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| project_id | UUID | NOT NULL, FK → Project(id) | +| number | VARCHAR(50) | NOT NULL (auto-generated) | +| status | ENUM('draft','sent','accepted','rejected') | DEFAULT 'draft' | +| valid_until | DATE | NULLABLE | +| template | VARCHAR(50) | NULLABLE | +| content_html | TEXT | NULLABLE (generated PDF content) | +| total_net | DECIMAL(12,2) | NULLABLE | +| total_gross | DECIMAL(12,2) | NULLABLE | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +**Indexes:** +- `ix_quote_account_id` on `account_id` +- `ix_quote_project_id` on `project_id` +- `ix_quote_status` on `status` + +--- + +### 2.23 Invoice +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| project_id | UUID | NOT NULL, FK → Project(id) | +| number | VARCHAR(50) | NOT NULL (auto-generated) | +| status | ENUM('draft','sent','paid','overdue','cancelled') | DEFAULT 'draft' | +| due_date | DATE | NULLABLE | +| total_net | DECIMAL(12,2) | NULLABLE | +| total_gross | DECIMAL(12,2) | NULLABLE | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +**Indexes:** +- `ix_invoice_account_id` on `account_id` +- `ix_invoice_project_id` on `project_id` +- `ix_invoice_status` on `status` + +--- + +### 2.24 Template +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| type | ENUM('quote','invoice','packing_list','delivery_note') | NOT NULL | +| name | VARCHAR(100) | NOT NULL | +| content_html | TEXT | NOT NULL | +| is_default | BOOLEAN | DEFAULT FALSE | + +**Indexes:** +- `ix_template_account_id` on `account_id` + +--- + +### 2.25 Webhook +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| url | VARCHAR(2048) | NOT NULL | +| secret | VARCHAR(255) | NOT NULL | +| events | JSONB | NOT NULL (array of event names) | +| is_active | BOOLEAN | DEFAULT TRUE | + +**Indexes:** +- `ix_webhook_account_id` on `account_id` + +--- + +### 2.26 WebhookLog +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| webhook_id | UUID | NOT NULL, FK → Webhook(id) ON DELETE CASCADE | +| event | VARCHAR(100) | NOT NULL | +| request_body | JSONB | NOT NULL | +| response_status | INTEGER | NOT NULL | +| response_body | TEXT | NULLABLE | +| success | BOOLEAN | NOT NULL | +| created_at | TIMESTAMPTZ | DEFAULT now() | + +**Indexes:** +- `ix_webhooklog_webhook_id` on `webhook_id` + +--- + +### 2.27 AuditLog +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| user_id | UUID | NOT NULL, FK → User(id) | +| action | VARCHAR(50) | NOT NULL (e.g., 'create', 'update', 'delete') | +| entity_type | VARCHAR(50) | NOT NULL (e.g., 'project', 'equipment') | +| entity_id | UUID | NOT NULL | +| details | JSONB | NULLABLE (changed fields) | +| timestamp | TIMESTAMPTZ | NOT NULL, DEFAULT now() | + +**Indexes:** +- `ix_auditlog_account_id` on `account_id` +- `ix_auditlog_user_id` on `user_id` +- `ix_auditlog_entity` on `entity_type`, `entity_id` +- `ix_auditlog_timestamp` on `timestamp` + +--- + +### 2.28 EquipmentGroup (Bundle) +| Column | Type | Constraints | +|--------|------|-------------| +| id | UUID | PK | +| account_id | UUID | NOT NULL, FK → Account(id) | +| name | VARCHAR(255) | NOT NULL | + +**Relationships:** +- Items stored in junction table `equipment_group_items` with columns `equipment_group_id (FK)`, `equipment_id (FK)`, `quantity` (INTEGER). + +--- + +## 3. Junction / Many-to-Many Tables + +### 3.1 EquipmentGroupItems +| Column | Type | Constraints | +|--------|------|-------------| +| equipment_group_id | UUID | NOT NULL, FK → EquipmentGroup(id) ON DELETE CASCADE | +| equipment_id | UUID | NOT NULL, FK → Equipment(id) ON DELETE CASCADE | +| quantity | INTEGER | NOT NULL | +| PRIMARY KEY (equipment_group_id, equipment_id) | + +### 3.2 EquipmentTags +| Column | Type | Constraints | +|--------|------|-------------| +| equipment_id | UUID | NOT NULL, FK → Equipment(id) ON DELETE CASCADE | +| tag_id | UUID | NOT NULL, FK → Tag(id) ON DELETE CASCADE | +| PRIMARY KEY (equipment_id, tag_id) | + +### 3.3 ProjectTags +| Column | Type | Constraints | +|--------|------|-------------| +| project_id | UUID | NOT NULL, FK → Project(id) ON DELETE CASCADE | +| tag_id | UUID | NOT NULL, FK → Tag(id) ON DELETE CASCADE | +| PRIMARY KEY (project_id, tag_id) | + +### 3.4 CrewTags +| Column | Type | Constraints | +|--------|------|-------------| +| crew_id | UUID | NOT NULL, FK → Crew(id) ON DELETE CASCADE | +| tag_id | UUID | NOT NULL, FK → Tag(id) ON DELETE CASCADE | +| PRIMARY KEY (crew_id, tag_id) | + +--- + +## 4. Migration Strategy + +We use Alembic for database migrations. The migration environment is configured with `async` support for SQLAlchemy (since our backend uses async sessions). + +Initial migration creates all tables with UUID generation appropriate to the dialect: +- PostgreSQL: `server_default=func.gen_random_uuid()` +- SQLite: a Python side UUID generation on insert (via model default) + +For development, SQLite will be used; however, tests should run against PostgreSQL to catch dialect-specific issues early. + +--- + +## 5. Key Database Constraints + +- **Unique constraints:** User `email` globally; Equipment `barcode` and `qr_code` per account (not globally, since multi-tenant). We'll make `(account_id, barcode)` unique if needed. +- **Foreign key cascades:** Sub-objects (subprojects, functions, equipment groups, crew assignments) should be deleted when the parent project is deleted (CASCADE). AuditLog and other history are kept (NO ACTION or SET NULL). +- **Check constraints:** Project `planperiod_end >= planperiod_start`, etc. +- **Default values:** Most ENUMs have a sensible default. diff --git a/.a0/env.md b/.a0/env.md new file mode 100644 index 0000000..e4877b3 --- /dev/null +++ b/.a0/env.md @@ -0,0 +1,14 @@ +# Environment Variables + +## Required +| Variable | Description | Default | +|---|---|---| + +## Optional +| Variable | Description | Default | +|---|---|---| + +## .env.example +``` +# Copy to .env and fill in values +``` diff --git a/.a0/healthcheck.md b/.a0/healthcheck.md new file mode 100644 index 0000000..52af729 --- /dev/null +++ b/.a0/healthcheck.md @@ -0,0 +1,12 @@ +# Healthcheck + +## Endpoint + +## Expected Response + +## How to Check +```bash +curl http://localhost:PORT/health +``` + +## Troubleshooting diff --git a/.a0/known_errors.md b/.a0/known_errors.md new file mode 100644 index 0000000..347e6b1 --- /dev/null +++ b/.a0/known_errors.md @@ -0,0 +1,20 @@ +# Known Errors + +## Open Errors +- (none) + +## Resolved Errors +- (none) + +## Won't Fix +- (none) + +## Error Log Format +``` +### Error: +- Status: open | in_progress | resolved | wont_fix +- Discovered: <timestamp> +- Severity: critical | high | medium | low +- Context: <description> +- Resolution: <how it was fixed or workaround> +``` diff --git a/.a0/next_steps.md b/.a0/next_steps.md new file mode 100644 index 0000000..55577fb --- /dev/null +++ b/.a0/next_steps.md @@ -0,0 +1,36 @@ +# Next Steps + +## Completed Phases +- Phase 0: Project Setup (T001-T003) +- Phase 1: Auth System (T004-T008) +- Phase 2: Contacts & Tags (T009-T011) + +## Next Phase: 3 – Equipment Catalog + +### Priority Tasks +1. **T012**: Create database models for Equipment catalog and StockLocation + - Equipment model: name, category, brand, serial_number, barcode, qr_code, status, purchase_price, current_value, weight_kg, dimensions, power_watt, notes, custom_fields + - StockLocation model: name, address, is_default + - EquipmentGroup (Bundle) with M2M items + - Relationships to Account, Contact (supplier?) + - Alembic migration + +2. **T013**: Implement Equipment catalog API + - CRUD endpoints: GET/POST /api/v1/equipment, GET/PUT/DELETE /api/v1/equipment/{id} + - Search, filter by category/status/location, pagination + - Auto-generate barcode/QR code + - Batch import/export CSV + - Permission: equipment:read, equipment:write, equipment:delete + +3. **T014**: Equipment catalog UI + - Equipment list page with grid/table toggle + - Search/filter by category, status, location + - Detail page with image, edit form + - Barcode/QR code display and print label + +## Implementation Notes +- Use existing patterns from contacts module: models → schemas → router → frontend pages +- All endpoints must use tenant isolation (account_id filter) +- All endpoints must use RBAC permissions via require_permission dependency +- Frontend uses existing API client, Zustand for auth, React state for data +- AppLayout sidebar already has Equipment link (placeholder) diff --git a/.a0/orchestrator_mode.json b/.a0/orchestrator_mode.json new file mode 100644 index 0000000..25bcc78 --- /dev/null +++ b/.a0/orchestrator_mode.json @@ -0,0 +1,21 @@ +{ + "mode": "planning_only", + "allowed_actions": [ + "read", + "analyze", + "write_specs", + "write_plan", + "ask_user" + ], + "blocked_actions": [ + "edit_source_code", + "install_dependency", + "run_deploy", + "push", + "delete" + ], + "requires_user_approval_to_enter": [ + "implementation_allowed", + "deployment_allowed" + ] +} diff --git a/.a0/project_state.json b/.a0/project_state.json new file mode 100644 index 0000000..ded767a --- /dev/null +++ b/.a0/project_state.json @@ -0,0 +1,80 @@ +{ + "project": { + "name": "rentman-clone", + "root": "/a0/usr/workdir/dev-projects/rentman-clone", + "git_provider": "local", + "repo_url": "", + "default_branch": "main", + "active_branch": "main", + "deployment_target": "local-development" + }, + "phase": { + "current": "implementation", + "previous": "architecture", + "next": "runtime_verification", + "blocked": false, + "blocker": null + }, + "progress": { + "requirements_done": true, + "design_done": true, + "tasks_done": true, + "implementation_started": true, + "implementation_progress": { + "completed_phases": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6 + ], + "completed_tasks": [ + "T001", + "T002", + "T003", + "T004", + "T005", + "T006", + "T007", + "T008", + "T009", + "T010", + "T011", + "T012", + "T013", + "T014", + "T015", + "T016", + "T017", + "T018", + "T019", + "T020", + "T021", + "T022", + "T023" + ], + "current_phase": 6, + "next_task": "T024" + }, + "tests_run": false, + "runtime_verified": false, + "deployment_docs_ready": false, + "release_ready": false + }, + "quality": { + "open_errors": 0, + "open_risks": 0, + "last_test_result": "not_run", + "last_runtime_result": "not_run", + "last_artifact_check": "not_run", + "score": 0 + }, + "next_action": { + "summary": "Phase 6: Projects (T022-T026+). T022 abgeschlossen. Nächstes: T023 – Project list and detail UI.", + "owner_agent": "implementation_engineer", + "required_skill": null + }, + "plan_mode": "implementation_allowed" +} \ No newline at end of file diff --git a/.a0/requirements.md b/.a0/requirements.md new file mode 100644 index 0000000..56ff15c --- /dev/null +++ b/.a0/requirements.md @@ -0,0 +1,1092 @@ +# Funktionaler Anforderungskatalog – Rentman.io Klon + +> **Version:** 1.0 +> **Datum:** 2026-05-31 +> **Projekt:** Rentman.io-Nachbau mit ≥90% Funktionsumfang +> **Autor:** Requirements Analyst (A0 Software Orchestrator) + +--- + +## Inhaltsverzeichnis + +1. [Projektübersicht & Vision](#1-projektübersicht--vision) +2. [Systemkontext & Benutzerrollen](#2-systemkontext--benutzerrollen) +3. [Datenmodell – Kernentitäten & Beziehungen](#3-datenmodell--kernentitäten--beziehungen) +4. [Funktionale Anforderungen – User Stories](#4-funktionale-anforderungen--user-stories) + - [4.1 Projektmanagement](#41-projektmanagement) + - [4.2 Equipment-Management](#42-equipment-management) + - [4.3 Crew & Personal](#43-crew--personal) + - [4.4 Fahrzeuge & Transport](#44-fahrzeuge--transport) + - [4.5 Kunden & Kontakte](#45-kunden--kontakte) + - [4.6 Finanzen](#46-finanzen) + - [4.7 Projektanfragen](#47-projektanfragen) + - [4.8 Scheduling & Zeitplanung](#48-scheduling--zeitplanung) + - [4.9 Dokumenten-Management](#49-dokumenten-management) + - [4.10 Benutzer & Rollen](#410-benutzer--rollen) + - [4.11 Reporting & Analytik](#411-reporting--analytik) + - [4.12 Mobile App](#412-mobile-app) + - [4.13 Integrationen](#413-integrationen) +5. [UI-Komponenten-Matrix](#5-ui-komponenten-matrix) +6. [Nicht-funktionale Anforderungen](#6-nicht-funktionale-anforderungen) +7. [Annahmen](#7-annahmen) +8. [Nicht-Ziele (Out of Scope)](#8-nicht-ziele-out-of-scope) +9. [Offene Fragen](#9-offene-fragen) + +--- + +## 1. Projektübersicht & Vision + +**Rentman.io** ist eine projektzentrische SaaS-Plattform für die Vermietungs- und Event-Branche (AV, Licht, Ton, Bühne). Die Kernphilosophie: **Alles gehört zu einem Projekt.** + +### Vision für den Klon + +Ein eigenständiger, voll funktionsfähiger Nachbau mit: +- Webanwendung (Desktop + Tablet) +- Mobile App (PWA oder nativ) +- REST API +- Multi-Tenant-Fähigkeit (mehrere Firmen/Accounts) +- ≥90% Funktionsumfang des Originals + +### Zielgruppe + +- AV- und Event-Technik-Verleiher +- Event-Agenturen mit eigenem Equipment-Pool +- Messebau-Firmen +- Klein- bis Mittelständische Betriebe sowie größere Verleihhäuser + +--- + +## 2. Systemkontext & Benutzerrollen + +### System-Kontext-Diagramm (textuell) + +``` +┌──────────────────────────────────────────────────────────┐ +│ Rentman-Klon │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ +│ │ Projekte │ │Equipment │ │ Crew │ │Fahrzeuge│ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │ +│ └──────────────┴────────────┴──────────────┘ │ +│ │ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Finanzen │ │Kontakte │ │Dokumente │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ REST API / Webhooks │ │ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ + │ │ │ + ┌────┴────┐ ┌────┴────┐ ┌─────┴──────┐ + │ Web UI │ │Mobile App│ │ Externe │ + │(Desktop)│ │(PWA) │ │ Systeme │ + └─────────┘ └─────────┘ └────────────┘ +``` + +### Benutzerrollen + +| Rolle | Beschreibung | Zentrale Berechtigungen | +|-------|-------------|------------------------| +| **Admin** | Vollzugriff auf alle Module und Einstellungen | Benutzerverwaltung, Systemkonfiguration, Abrechnung | +| **Projektleiter** | Verwaltet eigene Projekte, Crew und Equipment | Projekte CRUD, Crew-Zuweisung, Finanzübersicht | +| **Lagerist** | Equipment-Tracking, Packlisten, Status-Updates | Equipment-Scanning, Status-Änderung, Lagerort | +| **Freelancer** | Eingeschränkter Datenzugriff, Anwesenheit melden | Projekt-Details (read-only), eigene Verfügbarkeit, Aufgaben | +| **Kunde (Gast)** | Projektanfragen stellen, Angebote einsehen | Projektanfrage-Portal, Angebots-Download | + +--- + +## 3. Datenmodell – Kernentitäten & Beziehungen + +### Entitäten-Diagramm (textuell) + +``` +Account (Tenant) + │ + ├─ User (Benutzer) + │ └─ Role (Rolle) + │ + ├─ Contact (Kontakt: Firma/Person) + │ + ├─ Project (Projekt) + │ ├─ Subproject (Subprojekt) + │ ├─ ProjectFunctionGroup (Funktionsgruppe) + │ │ └─ ProjectFunction (Funktion: Crew/Equipment) + │ │ └─ ProjectCrew (Crew-Zuweisung) + │ │ └─ Rate (Preis/Kosten) + │ ├─ ProjectEquipmentGroup (Equipment-Gruppe im Projekt) + │ │ └─ ProjectEquipment (Equipment-Position) + │ ├─ Quote (Angebot) + │ ├─ Invoice (Rechnung) + │ └─ ProjectRequest (Projektanfrage) + │ └─ ProjectRequestEquipment (Equipment in Anfrage) + │ + ├─ Equipment (Equipment-Katalog) + │ ├─ EquipmentGroup (Bundle/Set) + │ ├─ StockLocation (Lagerort) + │ └─ EquipmentStatusLog (Tracking-Log) + │ + ├─ Crew (Crew-Mitglied) + │ └─ CrewAvailability (Verfügbarkeit) + │ + ├─ Vehicle (Fahrzeug) + │ └─ VehicleAssignment (Fahrzeug-Zuweisung) + │ + ├─ Document (Dokument/Datei) + └─ Tag (Schlagwörter/Farben) +``` + +### Felddefinitionen pro Entität + +#### Project + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| name | String(255) | Projektname | ✅ | +| reference | String(50) | Externe Referenz (z.B. Auftragsnummer) | | +| number | String(50) | Interne Projektnummer (auto-inkrement) | Auto | +| displayname | String | Anzeigename | Auto | +| customer | FK→Contact | Auftraggeber (Firma oder Person) | | +| contact_person | FK→Contact | Ansprechpartner beim Kunden | | +| account_manager | FK→User | Verantwortlicher Projektleiter | | +| project_type | Enum | Projekttyp (z.B. Vermietung, Dienstleistung, Verkauf) | | +| planperiod_start | DateTime | Planungsbeginn (Logistik) | ✅ | +| planperiod_end | DateTime | Planungsende (Logistik) | ✅ | +| usageperiod_start | DateTime | Nutzungsbeginn (vor Ort) | ✅ | +| usageperiod_end | DateTime | Nutzungsende (vor Ort) | ✅ | +| status | Enum | Status (Anfrage, Geplant, Bestätigt, Aktiv, Abgeschlossen, Storniert) | | +| location_name | String(255) | Veranstaltungsort | | +| location_street | String(255) | Straße | | +| location_city | String(100) | Stadt | | +| location_postalcode | String(20) | PLZ | | +| location_country | String(100) | Land | | +| remark | Text | Interne Bemerkung | | +| price | Decimal | Gesamtpreis (kalkuliert) | Readonly | +| cost | Decimal | Gesamtkosten (kalkuliert) | Readonly | +| created_at | DateTime | Erstellungsdatum | Auto | +| updated_at | DateTime | Letzte Änderung | Auto | +| archived | Boolean | Archiviert | | + +#### Subproject + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Übergeordnetes Projekt | ✅ | +| name | String(255) | Subprojekt-Name | ✅ | +| custom | JSON | Benutzerdefinierte Felder | | + +#### ProjectFunctionGroup + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Zugehöriges Projekt | ✅ | +| subproject | FK→Subproject | Zugehöriges Subprojekt (optional) | | +| name | String(255) | Name (z.B. "Ton", "Licht") | ✅ | +| usageperiod_start | DateTime | Nutzungsbeginn (überschreibt Projekt) | | +| usageperiod_end | DateTime | Nutzungsende (überschreibt Projekt) | | +| planperiod_start | DateTime | Planungsbeginn (überschreibt Projekt) | | +| planperiod_end | DateTime | Planungsende (überschreibt Projekt) | | +| remark | Text | Bemerkung | | + +#### ProjectFunction + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Zugehöriges Projekt | ✅ | +| subproject | FK→Subproject | Subprojekt (optional) | | +| group | FK→ProjectFunctionGroup | Funktionsgruppe (optional) | | +| name | String(255) | Funktionsname (z.B. "Tontechniker", "PA-Anlage") | ✅ | +| type | Enum | crew_function / equipment_function / transport_function | ✅ | +| amount | Integer | Anzahl | | +| usageperiod_start | DateTime | Individueller Nutzungsbeginn | | +| usageperiod_end | DateTime | Individuelles Nutzungsende | | +| planperiod_start | DateTime | Individueller Planungsbeginn | | +| planperiod_end | DateTime | Individuelles Planungsende | | +| cost_rate | Decimal | Interner Kostensatz | | +| price_rate | Decimal | Verkaufspreissatz | | +| remark | Text | Bemerkung | | + +#### ProjectEquipmentGroup + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Zugehöriges Projekt | ✅ | +| subproject | FK→Subproject | Subprojekt (optional) | | +| name | String(255) | Gruppenname (z.B. "Audio") | ✅ | +| usageperiod_start | DateTime | Nutzungsbeginn | | +| usageperiod_end | DateTime | Nutzungsende | | +| planperiod_start | DateTime | Planungsbeginn | | +| planperiod_end | DateTime | Planungsende | | +| in_price_calculation | Boolean | In Preiskalkulation einbeziehen | | +| remark | Text | Bemerkung | | + +#### ProjectEquipment + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| equipment_group | FK→ProjectEquipmentGroup | Zugehörige Equipment-Gruppe | ✅ | +| equipment | FK→Equipment | Verknüpfter Equipment-Artikel | | +| name | String(255) | Freitext-Name (wenn kein Equipment verknüpft) | ✅ | +| quantity | Integer | Geplante Menge | ✅ | +| quantity_total | Integer | Verfügbare Gesamtmenge | | +| unit_price | Decimal | Einzelpreis | | +| discount | Decimal | Rabatt in % | | +| external_remark | Text | Externe Bemerkung (für Kunden sichtbar) | | +| internal_remark | Text | Interne Bemerkung | | + +#### Equipment (Katalog) + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| name | String(255) | Artikelname | ✅ | +| category | String(100) | Kategorie (Licht, Ton, Video, Rigging...) | | +| brand | String(100) | Hersteller/Marke | | +| model | String(100) | Modellbezeichnung | | +| serial_number | String(100) | Seriennummer | | +| barcode | String(100) | Barcode/QR-Code | Unique | +| qr_code | String(100) | QR-Code (alternativ zum Barcode) | Unique | +| stock_location | FK→StockLocation | Standard-Lagerort | | +| status | Enum | Verfügbar, In Wartung, Defekt, Ausgemustert | | +| purchase_price | Decimal | Einkaufspreis | | +| current_value | Decimal | Aktueller Wert (mit Abschreibung) | | +| weight_kg | Decimal | Gewicht in kg | | +| dimensions_cm | String(50) | Maße (L×B×H) | | +| power_watt | Integer | Leistungsaufnahme in Watt | | +| custom_fields | JSON | Benutzerdefinierte Felder | | +| notes | Text | Notizen | | +| image_url | URL | Bild-URL | | + +#### StockLocation + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| name | String(100) | Bezeichnung (z.B. "Regal A3", "Halle 2") | ✅ | +| address | String(255) | Adresse (falls externes Lager) | | +| is_default | Boolean | Standardlager | | + +#### Crew + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| first_name | String(100) | Vorname | ✅ | +| last_name | String(100) | Nachname | ✅ | +| email | Email | E-Mail | | +| phone | String(50) | Telefon | | +| type | Enum | internal / freelancer | ✅ | +| user | FK→User | Verknüpfter Benutzeraccount (optional) | | +| hourly_rate | Decimal | Stundensatz | | +| daily_rate | Decimal | Tagessatz | | +| skills | JSON | Fähigkeiten/Tags (z.B. ["Ton", "Licht"]) | | +| is_active | Boolean | Aktiv/Inaktiv | | +| note | Text | Interne Notiz | | + +#### CrewAvailability + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| crew | FK→Crew | Crew-Mitglied | ✅ | +| start | DateTime | Beginn der Unverfügbarkeit / Verfügbarkeit | ✅ | +| end | DateTime | Ende | ✅ | +| type | Enum | available / unavailable / tentative | | +| reason | String(255) | Grund (Urlaub, gebucht, Krank...) | | + +#### Vehicle + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| name | String(100) | Fahrzeugname | ✅ | +| license_plate | String(20) | Kennzeichen | | +| type | String(50) | Typ (LKW, Sprinter, PKW) | | +| payload_kg | Integer | Zuladung in kg | | +| volume_m3 | Decimal | Ladevolumen in m³ | | +| is_active | Boolean | Aktiv | | + +#### VehicleAssignment + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| vehicle | FK→Vehicle | Fahrzeug | ✅ | +| project | FK→Project | Projekt | ✅ | +| start | DateTime | Beginn | ✅ | +| end | DateTime | Ende | ✅ | +| driver | FK→Crew | Fahrer (optional) | | + +#### Contact + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| type | Enum | company / person | ✅ | +| company_name | String(255) | Firmenname (wenn type=company) | * | +| first_name | String(100) | Vorname | * | +| last_name | String(100) | Nachname | * | +| email | Email | E-Mail | | +| phone | String(50) | Telefon | | +| mobile | String(50) | Mobil | | +| street | String(255) | Straße | | +| number | String(20) | Hausnummer | | +| postalcode | String(20) | PLZ | | +| city | String(100) | Stadt | | +| country | String(100) | Land | | +| tax_number | String(50) | USt-ID / Steuernummer | | +| note | Text | Notiz | | + +#### ProjectRequest (Projektanfrage) + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| name | String(255) | Anfragename/Eventname | ✅ | +| contact_name | String(255) | Name des Anfragenden | | +| contact_person_first_name | String(100) | Vorname Ansprechpartner | | +| contact_person_lastname | String(100) | Nachname Ansprechpartner | | +| contact_person_email | Email | E-Mail Ansprechpartner | | +| location_name | String(255) | Veranstaltungsort | | +| location_mailing_street | String(255) | Straße | | +| location_mailing_number | String(20) | Hausnummer | | +| location_mailing_postalcode | String(20) | PLZ | | +| location_mailing_city | String(100) | Stadt | | +| location_mailing_country | String(100) | Land | | +| planperiod_start | DateTime | Planungsbeginn | | +| planperiod_end | DateTime | Planungsende | | +| usageperiod_start | DateTime | Nutzungsbeginn | | +| usageperiod_end | DateTime | Nutzungsende | | +| status | Enum | Neu, In Bearbeitung, Konvertiert, Abgelehnt | Auto | +| remark | Text | Bemerkung | | + +#### ProjectRequestEquipment + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project_request | FK→ProjectRequest | Zugehörige Anfrage | ✅ | +| name | String(255) | Equipment-Name | ✅ | +| quantity | Integer | Menge | ✅ | +| quantity_total | Integer | Gesamtmenge | | +| unit_price | Decimal | Einzelpreis | | +| linked_equipment | FK→Equipment | Verknüpftes Equipment (optional) | | + +#### Quote (Angebot) + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Zugehöriges Projekt | ✅ | +| number | String(50) | Angebotsnummer | Auto | +| status | Enum | Draft, Versendet, Angenommen, Abgelehnt | | +| valid_until | Date | Gültig bis | | +| template | String(50) | Template-Name | | +| content_html | Text | Generierter Inhalt | Readonly | +| total_net | Decimal | Netto-Gesamt | Readonly | +| total_gross | Decimal | Brutto-Gesamt | Readonly | +| created_at | DateTime | Erstellungsdatum | Auto | + +#### Invoice (Rechnung) + +| Feld | Typ | Beschreibung | Pflicht | +|------|-----|-------------|---------| +| id | UUID | Eindeutige ID | Auto | +| project | FK→Project | Zugehöriges Projekt | ✅ | +| number | String(50) | Rechnungsnummer | Auto | +| status | Enum | Draft, Versendet, Bezahlt, Überfällig, Storniert | | +| due_date | Date | Fälligkeitsdatum | | +| total_net | Decimal | Netto-Gesamt | Readonly | +| total_gross | Decimal | Brutto-Gesamt | Readonly | +| created_at | DateTime | Erstellungsdatum | Auto | + +--- + +## 4. Funktionale Anforderungen – User Stories + +### 4.1 Projektmanagement + +#### PM-01: Projekt anlegen +**Als** Projektleiter **möchte ich** ein neues Projekt mit grundlegenden Informationen anlegen, **damit** es als zentraler Container für alle Planungen dient. + +**Akzeptanzkriterien:** +- [ ] Formular mit Feldern: Name*, Referenz, Nummer, Kunde, Projekttyp, Planungszeitraum*, Nutzungszeitraum*, Ort, Bemerkung +- [ ] Pflichtfelder werden validiert (clientseitig + serverseitig) +- [ ] Bei erfolgreicher Erstellung: Redirect auf Projekt-Detailseite +- [ ] Automatische Nummerngenerierung (konfigurierbares Präfix + laufende Nummer) +- [ ] Planungszeitraum und Nutzungszeitraum können unabhängig gesetzt werden + +#### PM-02: Projekt bearbeiten +**Als** Projektleiter **möchte ich** ein bestehendes Projekt bearbeiten können, **damit** ich Änderungen nachtragen kann. + +**Akzeptanzkriterien:** +- [ ] Alle Felder editierbar (außer systemgenerierte) +- [ ] Änderungen werden sofort persistiert +- [ ] Bei Datumsänderung: Warnung, falls Equipment/Crew bereits zugewiesen ist + +#### PM-03: Projekt löschen/archivieren +**Als** Admin **möchte ich** Projekte löschen oder archivieren können, **damit** inaktive Projekte nicht die Übersicht stören. + +**Akzeptanzkriterien:** +- [ ] Löschen: Nur möglich, wenn keine Rechnungen verknüpft sind → Soft-Delete +- [ ] Archivieren: Projekt in "Archiv" verschieben, alle Verknüpfungen bleiben erhalten +- [ ] Bestätigungsdialog vor Löschung + +#### PM-04: Projektliste mit Filtern +**Als** Projektleiter **möchte ich** eine filterbare, sortierbare Projektliste sehen, **damit** ich schnell relevante Projekte finde. + +**Akzeptanzkriterien:** +- [ ] Spalten: Name, Nummer, Kunde, Status, Zeitraum, Preis +- [ ] Filter: Status, Kunde, Projekttyp, Datum (von/bis), Suchtext +- [ ] Sortierung: Klick auf Spaltenkopf (auf-/absteigend) +- [ ] Paginierung (25/50/100 pro Seite) +- [ ] Gesamtzahl der Projekte wird angezeigt + +#### PM-05: Subprojekte verwalten +**Als** Projektleiter **möchte ich** innerhalb eines Projekts Subprojekte anlegen, **damit** ich große Events in logische Einheiten unterteilen kann. + +**Akzeptanzkriterien:** +- [ ] Subprojekt anlegen mit Name +- [ ] Subprojekt löschen (leer oder inkl. Inhalt) +- [ ] Subprojekte in Baumstruktur unter dem Hauptprojekt +- [ ] Anzahl Subprojekte unbegrenzt + +#### PM-06: Projekt-Funktionsgruppen und Funktionen +**Als** Projektleiter **möchte ich** Funktionsgruppen (z.B. "Ton", "Licht") und darin Funktionen (z.B. "Tontechniker", "PA-Anlage") definieren, **damit** ich den Personal- und Equipment-Bedarf strukturiere. + +**Akzeptanzkriterien:** +- [ ] Funktionsgruppe anlegen: Name, Subprojekt, eigene Zeiträume, Bemerkung +- [ ] Funktion anlegen: Name, Typ (crew/equipment/transport), Anzahl, Zeiträume, Kosten-/Preissatz +- [ ] Drag-and-Drop von Funktionen zwischen Gruppen +- [ ] Zeiträume von Gruppen auf Funktionen vererbt (überschreibbar) +- [ ] Summierung der Funktionen pro Gruppe und gesamt + +#### PM-07: Projekt-Status-Workflow +**Als** Projektleiter **möchte ich** den Projektstatus ändern können, **damit** der Projektfortschritt nachvollziehbar ist. + +**Akzeptanzkriterien:** +- [ ] Status-Übergänge: Anfrage → Geplant → Bestätigt → Aktiv → Abgeschlossen +- [ ] Storniert als eigener Status (keine weitere Bearbeitung) +- [ ] Status-Historie protokolliert (wer, wann, alter Status → neuer Status) +- [ ] Status-Änderung löst optionale Benachrichtigung aus + +#### PM-08: Projekt-Vorlagen +**Als** Projektleiter **möchte ich** ein Projekt als Vorlage speichern und daraus neue Projekte erstellen, **damit** ich wiederkehrende Events schnell anlegen kann. + +**Akzeptanzkriterien:** +- [ ] "Als Vorlage speichern" kopiert Projekt-Struktur (Funktionen, Gruppen, Equipment ohne konkrete Zuweisung) +- [ ] Neues Projekt aus Vorlage: Name und Zeiträume werden gesetzt, Rest übernommen +- [ ] Vorlagen-Liste mit Durchsuchen/Filtern + +--- + +### 4.2 Equipment-Management + +#### EQ-01: Equipment-Katalog pflegen +**Als** Lagerist **möchte ich** Equipment-Artikel im Katalog anlegen, bearbeiten und löschen, **damit** der Bestand aktuell bleibt. + +**Akzeptanzkriterien:** +- [ ] CRUD für Equipment mit allen Feldern gemäß Datenmodell +- [ ] Barcode/QR-Code automatisch generierbar und manuell setzbar +- [ ] Massen-Import via CSV/Excel +- [ ] Massen-Export in CSV/Excel +- [ ] Bilder-Upload pro Artikel + +#### EQ-02: Equipment-Kategorisierung & Suche +**Als** Lagerist **möchte ich** Equipment nach Kategorien filtern und durchsuchen, **damit** ich schnell finde, was ich brauche. + +**Akzeptanzkriterien:** +- [ ] Freitextsuche über Name, Marke, Modell, Seriennummer, Barcode +- [ ] Filter: Kategorie, Status, Lagerort +- [ ] Kategorie-Baum (hierarchisch, z.B. Ton → Mikrofone → Funkmikrofone) +- [ ] Anzeige: Kachel-Ansicht + Tabellen-Ansicht + +#### EQ-03: Equipment-Gruppen (Bundles) +**Als** Projektleiter **möchte ich** Equipment-Gruppen (Bundles/Sets) definieren, **damit** ich häufig zusammen verliehene Artikel als Einheit planen kann. + +**Akzeptanzkriterien:** +- [ ] Bundle anlegen: Name + Liste von Equipment-Artikeln mit Menge +- [ ] Bundle im Projekt wie Einzelartikel planbar +- [ ] Auflösung des Bundles in Einzelartikel bei Verfügbarkeitsprüfung + +#### EQ-04: Equipment zum Projekt hinzufügen +**Als** Projektleiter **möchte ich** Equipment direkt aus dem Katalog in eine Equipment-Gruppe des Projekts ziehen, **damit** die Planung schnell erfolgt. + +**Akzeptanzkriterien:** +- [ ] Equipment-Gruppe im Projekt anlegen +- [ ] Equipment-Position hinzufügen: Artikel auswählen (Such-Autocomplete), Menge, Preis +- [ ] Optional: Freitext-Position ohne Katalog-Verknüpfung +- [ ] Rabatt pro Position setzbar +- [ ] Drag-and-Drop aus Katalog in Projekt (optional, MVP: Formular) + +#### EQ-05: Equipment-Verfügbarkeit prüfen +**Als** Projektleiter **möchte ich** für jedes Equipment sehen, ob es im gewünschten Zeitraum verfügbar ist, **damit** ich Konflikte vermeide. + +**Akzeptanzkriterien:** +- [ ] Timeline-Ansicht pro Artikel: Belegungsblöcke +- [ ] Konflikt-Indikator in Equipment-Position (rot/grün/gelb) +- [ ] Massen-Verfügbarkeits-Check für ganze Projekte +- [ ] Alternative vorschlagen (gleiche Kategorie, verfügbar) + +#### EQ-06: Equipment-Status-Tracking (Logistik) +**Als** Lagerist **möchte ich** den Status jedes Artikels im Projekt ändern, **damit** ich den Logistik-Fortschritt tracken kann. + +**Akzeptanzkriterien:** +- [ ] Status: Geplant → (Gepackt) → Vor Ort → (Zurück) → Eingelagert +- [ ] Zusätzliche Stati: Verspätet, Fehlend, Beschädigt +- [ ] Status-Änderung via Web-UI (Klick) oder Barcode-Scan (Mobile App) +- [ ] Status-Historie pro Artikel und Projekt + +#### EQ-07: QR/Barcode-Generierung und Scan +**Als** Lagerist **möchte ich** Barcodes/QR-Codes für Equipment drucken und scannen, **damit** das Tracking effizient ist. + +**Akzeptanzkriterien:** +- [ ] Barcode/QR-Code pro Equipment-Item (Unique-ID-basiert) +- [ ] Druck-Layout: Label mit Name, Code, optional Projekt +- [ ] Scan via Mobile-App-Kamera oder USB-Barcode-Scanner +- [ ] Scan-Aktion: Status ändern, Info anzeigen + +#### EQ-08: Lagerort-Verwaltung +**Als** Lagerist **möchte ich** Lagerorte definieren und Equipment zuweisen, **damit** ich weiß, wo sich Artikel befinden. + +**Akzeptanzkriterien:** +- [ ] CRUD für Lagerorte: Name, Adresse +- [ ] Equipment: Standard-Lagerort + aktueller Standort +- [ ] Lagerort-Übersicht: Welches Equipment liegt wo? + +--- + +### 4.3 Crew & Personal + +#### CR-01: Crew-Katalog pflegen +**Als** Projektleiter **möchte ich** Crew-Mitglieder (intern + Freelancer) im Katalog verwalten, **damit** ich sie Projekten zuweisen kann. + +**Akzeptanzkriterien:** +- [ ] CRUD für Crew: Name, Kontakt, Typ, Stundensatz/Tagessatz, Skills +- [ ] Unterscheidung intern/Freelancer +- [ ] Skills als Tags (Mehrfachauswahl) +- [ ] CSV-Import/Export + +#### CR-02: Crew zu Projektfunktion zuweisen +**Als** Projektleiter **möchte ich** ein Crew-Mitglied einer Projektfunktion (z.B. "Tontechniker") zuweisen, **damit** die Personalplanung abgeschlossen ist. + +**Akzeptanzkriterien:** +- [ ] Projektfunktion als Crew-Funktion definiert (type=crew_function) +- [ ] Crew-Auswahl per Dropdown/Suche +- [ ] Warnung bei Verfügbarkeitskonflikt +- [ ] Mehrere Crew pro Funktion (wenn amount > 1) + +#### CR-03: Crew-Verfügbarkeitsplanung +**Als** Projektleiter **möchte ich** die Verfügbarkeit der Crew im Projektzeitraum sehen, **damit** ich keine Doppelbuchungen mache. + +**Akzeptanzkriterien:** +- [ ] Kalender-Ansicht pro Crew-Mitglied +- [ ] Unverfügbarkeiten eintragen: Datum, Typ, Grund +- [ ] Konflikt-Erkennung bei Zuweisung +- [ ] Monats-/Wochenübersicht für alle Crew + +#### CR-04: Freelancer-Self-Service +**Als** Freelancer **möchte ich** meine Verfügbarkeit selbst eintragen und meine Projekte einsehen, **damit** ich selbstbestimmt planen kann. + +**Akzeptanzkriterien:** +- [ ] Freelancer-Login (eingeschränkter Zugang) +- [ ] Eigene Verfügbarkeit eintragen/bearbeiten +- [ ] Zugewiesene Projekte sehen (read-only) +- [ ] Eigene Kontaktdaten bearbeiten + +--- + +### 4.4 Fahrzeuge & Transport + +#### FH-01: Fahrzeug-Katalog pflegen +**Als** Admin **möchte ich** Fahrzeuge im Katalog verwalten, **damit** Transport geplant werden kann. + +**Akzeptanzkriterien:** +- [ ] CRUD: Name, Kennzeichen, Typ, Zuladung, Volumen +- [ ] Aktiv/Inaktiv-Status + +#### FH-02: Fahrzeug zu Projekt zuweisen +**Als** Projektleiter **möchte ich** ein Fahrzeug einem Projekt zuweisen, **damit** der Transport gesichert ist. + +**Akzeptanzkriterien:** +- [ ] Zuweisung: Fahrzeug + Projekt + Zeitraum + Fahrer +- [ ] Verfügbarkeitsprüfung (keine Doppelzuweisung) +- [ ] Anzeige im Projekt als Transport-Funktion + +#### FH-03: Transport-Planungsübersicht +**Als** Projektleiter **möchte ich** eine Übersicht aller Fahrzeug-Zuweisungen, **damit** ich Transportkonflikte erkenne. + +**Akzeptanzkriterien:** +- [ ] Kalender-Ansicht mit allen Fahrzeugen +- [ ] Farbcodierung: eigenes Projekt vs. andere +- [ ] Filter: Fahrzeug, Zeitraum + +--- + +### 4.5 Kunden & Kontakte + +#### KK-01: Kontakte verwalten +**Als** Projektleiter **möchte ich** Firmen und Personen als Kontakte speichern, **damit** ich sie Projekten zuweisen kann. + +**Akzeptanzkriterien:** +- [ ] CRUD für Firmen (company) und Personen (person) +- [ ] Beziehung: Ansprechpartner (Person) gehört zu Firma (Company) +- [ ] Suche und Filter +- [ ] Verknüpfte Projekte anzeigen ("Welche Projekte hat Kunde X?") + +#### KK-02: Kunde zum Projekt zuweisen +**Als** Projektleiter **möchte ich** einen Kontakt als Kunden im Projekt setzen, **damit** die Zuordnung klar ist. + +**Akzeptanzkriterien:** +- [ ] Kunden-Auswahl per Autocomplete-Suche +- [ ] Adresse des Kunden als Veranstaltungsort übernehmbar +- [ ] Ansprechpartner separat wählbar + +--- + +### 4.6 Finanzen + +#### FI-01: Preiskalkulation im Projekt +**Als** Projektleiter **möchte ich** dass das System automatisch Gesamtpreise kalkuliert, **damit** ich Angebote auf Basis der Planung erstellen kann. + +**Akzeptanzkriterien:** +- [ ] Equipment-Positionen: Menge × Einzelpreis +- [ ] Crew-Funktionen: amount × Tagessatz/Stundensatz × Dauer +- [ ] Transport: Preis pro Fahrzeug +- [ ] Rabatte auf Positionen und global +- [ ] Steuer-Berechnung (MwSt. konfigurierbar) +- [ ] Echtzeit-Aktualisierung bei Änderungen + +#### FI-02: Angebote erstellen und verwalten +**Als** Projektleiter **möchte ich** ein Angebot aus der Projektplanung generieren, **damit** ich es dem Kunden senden kann. + +**Akzeptanzkriterien:** +- [ ] Angebot aus Projekt generieren: Alle Positionen übernehmen +- [ ] Angebots-Template auswählen +- [ ] Status: Entwurf → Versendet → Angenommen/Abgelehnt +- [ ] PDF-Export und E-Mail-Versand +- [ ] Angebots-Historie pro Projekt (Versionen) + +#### FI-03: Rechnungen erstellen und verwalten +**Als** Projektleiter **möchte ich** aus einem angenommenen Angebot eine Rechnung erstellen, **damit** ich abrechnen kann. + +**Akzeptanzkriterien:** +- [ ] Rechnung aus Angebot generieren (Positionen übernehmen) +- [ ] Teil-Rechnungen möglich (z.B. 50% Anzahlung) +- [ ] Rechnungsstatus: Entwurf → Versendet → Bezahlt/Überfällig/Storniert +- [ ] Mahnwesen: Überfällige Rechnungen markieren, Mahnung generieren +- [ ] PDF-Export und E-Mail-Versand +- [ ] Nummernkreis automatisch (konfigurierbar) + +#### FI-04: Währung & Steuern konfigurieren +**Als** Admin **möchte ich** Währung und Steuersätze global und pro Projekt konfigurieren, **damit** die Finanzberechnung korrekt ist. + +**Akzeptanzkriterien:** +- [ ] Standardwährung setzen (EUR, USD, CHF...) +- [ ] Standard-Steuersatz (z.B. 19%) +- [ ] Projekt kann abweichenden Steuersatz haben + +#### FI-05: Finanz-Übersicht +**Als** Admin **möchte ich** eine Übersicht über offene Posten und Einnahmen, **damit** ich die Liquidität im Blick habe. + +**Akzeptanzkriterien:** +- [ ] Dashboard: Offene Rechnungen, Umsatz (Monat/Jahr), Top-Kunden +- [ ] Filter: Zeitraum, Projektstatus +- [ ] Export-Funktion + +--- + +### 4.7 Projektanfragen (Project Requests) + +#### PA-01: Projektanfrage einreichen (Kunden-Portal) +**Als** potenzieller Kunde **möchte ich** über ein öffentliches Formular eine Projektanfrage stellen, **damit** ich ein Angebot erhalte. + +**Akzeptanzkriterien:** +- [ ] Öffentliches Formular (ohne Login): Event-Name, Kontaktdaten, Ort, Zeitraum, Bemerkung +- [ ] Validierung der Pflichtfelder +- [ ] Bestätigungs-E-Mail nach Einreichung + +#### PA-02: Equipment zur Anfrage hinzufügen +**Als** Kunde **möchte ich** gewünschtes Equipment in der Anfrage auflisten, **damit** der Anbieter meinen Bedarf kennt. + +**Akzeptanzkriterien:** +- [ ] Positionen hinzufügen: Name, Menge, optionaler Einzelpreis +- [ ] Mehrere Positionen möglich +- [ ] Möglichkeit, auf existierende Katalog-Artikel zu verweisen + +#### PA-03: Anfrage prüfen und in Projekt konvertieren +**Als** Projektleiter **möchte ich** eine eingegangene Anfrage prüfen und in ein Projekt umwandeln, **damit** die Planung beginnen kann. + +**Akzeptanzkriterien:** +- [ ] Anfrage-Liste mit Status +- [ ] Detailansicht der Anfrage mit allen Positionen +- [ ] "In Projekt umwandeln": Neues Projekt aus Anfrage-Daten erstellen +- [ ] Equipment-Positionen in Projekt übernehmen +- [ ] Status der Anfrage auf "Konvertiert" setzen + +--- + +### 4.8 Scheduling & Zeitplanung + +#### SC-01: Projekt-Timeline (Gantt-Ansicht) +**Als** Projektleiter **möchte ich** eine visuelle Timeline aller Projekte sehen, **damit** ich Überschneidungen erkenne. + +**Akzeptanzkriterien:** +- [ ] Horizontale Zeitleiste: Monate/Wochen/Tage +- [ ] Jedes Projekt als Balken (Planungszeitraum + Nutzungszeitraum) +- [ ] Farbcodierung nach Status oder Projekttyp +- [ ] Drag-and-Drop: Projektzeitraum ändern +- [ ] Zoom: Jahr, Monat, Woche, Tag + +#### SC-02: Equipment/Crew-Timeline (Ressourcen-Ansicht) +**Als** Projektleiter **möchte ich** in der Timeline sehen, welche Ressourcen belegt sind, **damit** ich Engpässe erkenne. + +**Akzeptanzkriterien:** +- [ ] Gruppierung: Equipment / Crew / Fahrzeuge +- [ ] Belegungsblöcke pro Ressource +- [ ] Konflikt-Hervorhebung (rot) +- [ ] Filter nach Kategorie/Typ + +#### SC-03: Kalender-Integration +**Als** Projektleiter **möchte ich** Projekte und Aufgaben in meinem externen Kalender sehen, **damit** ich alles an einem Ort habe. + +**Akzeptanzkriterien:** +- [ ] iCal/CalDAV-Feed pro Benutzer +- [ ] Abonnement-Link für Google Calendar, Outlook, Apple Calendar +- [ ] Option: Öffentlicher Projekt-Kalender + +#### SC-04: Aufgaben-Management +**Als** Projektleiter **möchte ich** Aufgaben innerhalb eines Projekts anlegen und zuweisen, **damit** nichts vergessen wird. + +**Akzeptanzkriterien:** +- [ ] Aufgaben-CRUD: Titel, Beschreibung, Fälligkeit, Verantwortlicher, Status +- [ ] Status: Offen, In Bearbeitung, Erledigt +- [ ] Aufgaben-Liste im Projekt und persönliche To-Do-Liste +- [ ] Erinnerungs-E-Mail vor Fälligkeit + +--- + +### 4.9 Dokumenten-Management + +#### DO-01: Angebots-Templates verwalten +**Als** Admin **möchte ich** Angebots-Templates mit eigenem Layout und Platzhaltern definieren, **damit** Corporate Design eingehalten wird. + +**Akzeptanzkriterien:** +- [ ] HTML/CSS-basierte Templates +- [ ] Platzhalter: {{project_name}}, {{customer_name}}, {{total_price}}, {{items_table}}, etc. +- [ ] Template-Vorschau mit Beispiel-Daten +- [ ] Mehrere Templates speicherbar + +#### DO-02: Rechnungs-Templates verwalten +**Als** Admin **möchte ich** Rechnungs-Templates definieren, **damit** Rechnungen dem Corporate Design entsprechen. + +**Akzeptanzkriterien:** +- [ ] Wie Angebots-Templates, jedoch mit Rechnungs-spezifischen Platzhaltern +- [ ] Rechtlich korrekte Pflichtangaben +- [ ] Zahlungsbedingungen konfigurierbar + +#### DO-03: Digitale Packliste generieren +**Als** Lagerist **möchte ich** eine Packliste aus der Equipment-Planung generieren, **damit** ich weiß, was gepackt werden muss. + +**Akzeptanzkriterien:** +- [ ] Packliste: Alle Equipment-Positionen mit Menge und Lagerort +- [ ] Gruppierung: Nach Equipment-Gruppe oder Lagerort +- [ ] Checkboxen zum Abhaken (digital + Druck) +- [ ] PDF-Export + +#### DO-04: Lieferscheine generieren +**Als** Projektleiter **möchte ich** einen Lieferschein für das Equipment erstellen, **damit** der Kunde den Empfang quittieren kann. + +**Akzeptanzkriterien:** +- [ ] Lieferschein: Equipment-Liste, Projekt-Info, Lieferadresse +- [ ] Unterschriftsfeld +- [ ] PDF-Export + +#### DO-05: Allgemeines Dokumenten-Management +**Als** Projektleiter **möchte ich** beliebige Dateien (PDF, Bilder, Pläne) zu einem Projekt hochladen, **damit** alle relevanten Dokumente zentral gespeichert sind. + +**Akzeptanzkriterien:** +- [ ] Datei-Upload per Drag-and-Drop +- [ ] Unterstützte Formate: PDF, JPG, PNG, DOCX, XLSX, DWG (mind. Vorschau) +- [ ] Dateien nach Kategorien ordnen +- [ ] Zugriffskontrolle (projektbezogen) + +--- + +### 4.10 Benutzer & Rollen + +#### BR-01: Benutzerverwaltung +**Als** Admin **möchte ich** Benutzer anlegen, bearbeiten und deaktivieren, **damit** das Team Zugriff hat. + +**Akzeptanzkriterien:** +- [ ] Benutzer-CRUD: Name, E-Mail, Passwort, Rolle +- [ ] Einladungs-Workflow: E-Mail mit Aktivierungslink +- [ ] Passwort-Reset über E-Mail +- [ ] Deaktivierung statt Löschung (Datenintegrität) + +#### BR-02: Rollen & Berechtigungen +**Als** Admin **möchte ich** Rollen mit granular steuerbaren Berechtigungen definieren, **damit** jeder nur sieht, was er braucht. + +**Akzeptanzkriterien:** +- [ ] Vordefinierte Rollen: Admin, Projektleiter, Lagerist, Freelancer +- [ ] Benutzerdefinierte Rollen möglich +- [ ] Berechtigungen: Modul-Zugriff (read/write/delete), Projekt-Zugriff (alle/eigene) +- [ ] Rollen-Matrix (UI für Konfiguration) + +#### BR-03: Benutzer-Aktivitätslog +**Als** Admin **möchte ich** sehen, wer welche Änderungen vorgenommen hat, **damit** es nachvollziehbar bleibt. + +**Akzeptanzkriterien:** +- [ ] Log-Einträge: Benutzer, Aktion, Entität, Zeitstempel +- [ ] Filter nach Benutzer, Aktionstyp, Datum +- [ ] Nur für Admins sichtbar + +--- + +### 4.11 Reporting & Analytik + +#### RE-01: Dashboard +**Als** Projektleiter **möchte ich** ein Dashboard mit den wichtigsten Kennzahlen sehen, **damit** ich den Überblick behalte. + +**Akzeptanzkriterien:** +- [ ] Widgets: Aktive Projekte, Heutige Events, Equipment-Auslastung, Crew-Auslastung, Offene Rechnungen +- [ ] Konfigurierbare Widgets (an/ab wählbar) +- [ ] Daten per Klick in Detail-Ansicht drill-down + +#### RE-02: Projekt-Statistiken +**Als** Admin **möchte ich** Statistiken zu Projekten, Umsatz, und Auslastung einsehen, **damit** ich das Geschäft analysieren kann. + +**Akzeptanzkriterien:** +- [ ] Umsatz pro Monat/Jahr, pro Kunde, pro Projekttyp +- [ ] Anzahl Projekte nach Status +- [ ] Durchschnittliche Projektdauer + +#### RE-03: Equipment-Auslastungsreport +**Als** Lagerist **möchte ich** einen Report über die Equipment-Auslastung, **damit** ich Einkaufsentscheidungen treffen kann. + +**Akzeptanzkriterien:** +- [ ] Auslastung pro Artikel (Belegungstage / verfügbare Tage) +- [ ] Top- und Flop-Artikel +- [ ] Zeitraum flexibel wählbar +- [ ] Export als CSV/Excel + +#### RE-04: Finanz-Report +**Als** Admin **möchte ich** einen Finanz-Report mit Umsatz und offenen Posten, **damit** ich die Buchhaltung unterstützen kann. + +**Akzeptanzkriterien:** +- [ ] Umsatz pro Monat (Ist vs. Plan) +- [ ] Offene Rechnungen mit Alter +- [ ] Kunden-Ranking + +--- + +### 4.12 Mobile App + +#### MO-01: Barcode/QR-Code-Scanning +**Als** Lagerist **möchte ich** mit dem Smartphone Equipment scannen, **damit** ich unterwegs Status-Änderungen vornehmen kann. + +**Akzeptanzkriterien:** +- [ ] Kamera-Scan von Barcode und QR-Code +- [ ] Nach Scan: Info anzeigen + Aktion wählen (Status ändern, Packen, Einlagern) +- [ ] Vibrations-/Ton-Feedback bei erfolgreichem Scan + +#### MO-02: Packliste digital abhaken +**Als** Lagerist **möchte ich** auf der Mobile App die Packliste durchgehen und abhaken, **damit** nichts vergessen wird. + +**Akzeptanzkriterien:** +- [ ] Packliste anzeigen (pro Projekt) +- [ ] Checkbox pro Position +- [ ] Fortschrittsbalken (X/Y gepackt) +- [ ] Offline: Letzter Stand lokal gespeichert, Sync bei Verbindung + +#### MO-03: Verfügbarkeits-Check mobil +**Als** Projektleiter **möchte ich** unterwegs schnell prüfen, ob Equipment verfügbar ist, **damit** ich beim Kunden direkt antworten kann. + +**Akzeptanzkriterien:** +- [ ] Suchfeld mit Autocomplete +- [ ] Ergebnis: Verfügbarkeits-Status (frei/belegt) + Belegungsdetails + +#### MO-04: Offline-Fähigkeit +**Als** Lagerist **möchte ich** die App auch ohne Internet nutzen können, **damit** ich in Hallen/Kellern ohne Empfang arbeiten kann. + +**Akzeptanzkriterien:** +- [ ] Projektdaten und Equipment-Listen offline cachen +- [ ] Änderungen lokal speichern und bei Verbindung synchronisieren +- [ ] Konflikt-Erkennung: Warnung, wenn Daten zwischenzeitlich geändert wurden +- [ ] Sync-Status-Anzeige + +--- + +### 4.13 Integrationen + +#### INT-01: REST API +**Als** Entwickler **möchte ich** eine vollständige REST API, **damit** ich externe Systeme anbinden kann. + +**Akzeptanzkriterien:** +- [ ] API-Endpunkte für alle Kernentitäten (≥80% des Datenmodells) +- [ ] Authentifizierung per API-Token (JWT/Bearer) +- [ ] API-Dokumentation (OpenAPI/Swagger) +- [ ] Rate Limiting +- [ ] Versionierung (/api/v1/...) + +#### INT-02: Webhooks +**Als** Admin **möchte ich** Webhooks konfigurieren, **damit** externe Systeme automatisch über Ereignisse informiert werden. + +**Akzeptanzkriterien:** +- [ ] Webhook-Ereignisse: Projekt erstellt, Status geändert, Rechnung erstellt, Ausrüstung gescannt +- [ ] Webhook-URL + Secret konfigurierbar +- [ ] Retry-Logik bei Fehlschlag +- [ ] Webhook-Log (Erfolg/Fehler) + +#### INT-03: Excel/CSV Import/Export +**Als** Admin **möchte ich** Daten via Excel/CSV importieren und exportieren, **damit** ich bestehende Daten migrieren kann. + +**Akzeptanzkriterien:** +- [ ] Import: Equipment, Crew, Kontakte +- [ ] Validierung mit Fehlerbericht +- [ ] Dry-Run-Modus (Vorschau) +- [ ] Export: Projektdaten, Equipment-Liste, Reports + +--- + +## 5. UI-Komponenten-Matrix + +| Modul | Hauptseiten | Schlüssel-Komponenten | Interaktionsmuster | +|-------|------------|----------------------|-------------------| +| **Projekte** | Liste, Detail, Formular | Filter-Toolbar, Status-Badge, Timeline-Miniatur, Tab-Navigation (Übersicht/Funktionen/Equipment/Crew/Finanzen/Dokumente) | Inline-Editing, Drag-and-Drop für Funktionen | +| **Equipment** | Katalog, Detail, Scan-View | Suchfeld mit Autocomplete, Kachel/Tabelle-Toggle, Barcode-Scan-Button, Verfügbarkeits-Kalender | Barcode-Scan, Massen-Selektion, Drag-in-Projekt | +| **Crew** | Katalog, Detail, Verfügbarkeit | Profilkarte, Skill-Tags, Verfügbarkeits-Kalender | Drag-and-Drop in Projektfunktion | +| **Fahrzeuge** | Katalog, Zuweisung | Fahrzeug-Kachel, Transport-Kalender | Drag-and-Drop in Projekt | +| **Kontakte** | Liste, Detail | Firmen-Baum + Ansprechpartner-Tabelle, Projekt-Verknüpfungen | Inline-Editing | +| **Finanzen** | Angebote-Liste, Rechnungen-Liste, Template-Editor | PDF-Preview, E-Mail-Dialog, Zahlungsstatus-Badge | Template-Builder (WYSIWYG oder HTML) | +| **Anfragen** | Liste, Detail, Konvertierungs-Dialog | Status-Badge, Konvertierungs-Button, Positions-Tabelle | Formular-basiert | +| **Scheduling** | Gantt-Timeline, Kalender | Horizontale Zeitleiste, Drag-and-Drop-Balken, Zoom-Slider, Ressourcen-Filter | Drag-and-Drop, Scroll-Zoom | +| **Dokumente** | Datei-Liste, Template-Editor | Upload-Zone (Drag-and-Drop), Datei-Vorschau, Ordner-Baum | Drag-and-Drop-Upload | +| **Dashboard** | Dashboard-Startseite | Widget-Grid, Drill-Down-Klick, Zeitraum-Filter | Widget-Konfiguration, Responsive Grid | +| **Admin** | Benutzer, Rollen, Einstellungen | Rollen-Matrix (Checkbox-Grid), Aktivitätslog-Tabelle | Matrix-Editor | +| **Mobile App** | Scan, Packliste, Suche | Kamera-View mit Overlay, Checkliste mit Fortschritt, Sync-Status-Indikator | Touch-optimiert, Offline-First | + +--- + +## 6. Nicht-funktionale Anforderungen + +### Performance & Skalierbarkeit + +| ID | Anforderung | Messkriterium | +|----|------------|---------------| +| NF-P01 | Seiten-Ladezeit | Initial Page Load < 2s (P95), Subsequent Navigation < 500ms | +| NF-P02 | API-Response-Zeit | < 200ms (P95) für GET-Endpunkte, < 500ms für POST/PUT | +| NF-P03 | Datenbank-Abfragen | Maximale Datenmenge: 100.000 Projekte, 500.000 Equipment-Items | +| NF-P04 | Parallelität | Mindestens 50 gleichzeitige Benutzer ohne spürbare Degradation | +| NF-P05 | Offline-Sync | Mobile App: Konflikt-Auflösung bei ≤100 Offline-Änderungen | + +### Sicherheit + +| ID | Anforderung | Beschreibung | +|----|------------|-------------| +| NF-S01 | Authentifizierung | JWT-basiert, Token-Ablauf konfigurierbar (Standard: 24h), Refresh-Token | +| NF-S02 | Autorisierung | Rollenbasierte Zugriffskontrolle (RBAC) auf Modul- und Objektebene | +| NF-S03 | HTTPS | Ausschließlich TLS 1.3, HSTS-Header | +| NF-S04 | Passwort-Policy | Mindestens 8 Zeichen, Komplexitätsanforderung, bcrypt-Hashing | +| NF-S05 | API-Sicherheit | Rate Limiting (100 req/min pro Token), Input-Validierung, SQL-Injection-Schutz | +| NF-S06 | Daten-Isolation | Multi-Tenant: Strikte Trennung der Account-Daten | +| NF-S07 | Audit-Log | Alle schreibenden Operationen protokolliert mit Benutzer + Zeitstempel | +| NF-S08 | DSGVO-Konformität | Recht auf Auskunft/Löschung, Auftragsverarbeitungsvertrag-fähig | + +### Zuverlässigkeit + +| ID | Anforderung | Beschreibung | +|----|------------|-------------| +| NF-R01 | Verfügbarkeit | 99,5% Uptime (außerhalb geplanter Wartungsfenster) | +| NF-R02 | Backup | Automatisches tägliches Backup, 30 Tage Retention | +| NF-R03 | Disaster Recovery | Wiederherstellungszeit < 4h, maximal 24h Datenverlust (RPO) | + +### Benutzerfreundlichkeit + +| ID | Anforderung | Beschreibung | +|----|------------|-------------| +| NF-U01 | Responsive Design | Optimiert für Desktop (1920px), Tablet (1024px), Mobile (375px) | +| NF-U02 | Barrierefreiheit | WCAG 2.1 Level AA (Kontrast, Tastatur-Navigation, Screenreader) | +| NF-U03 | Internationalisierung | Mehrsprachig (DE, EN). Weitere Sprachen per Übersetzungsdatei ergänzbar | +| NF-U04 | Browser-Support | Chrome, Firefox, Safari, Edge – jeweils letzte 2 Major-Versionen | + +### Technische Architektur-Präferenzen (Diskussionsbasis) + +- **Frontend:** React mit TypeScript (Mobile PWA mit Capacitor) +- **Backend:** Python FastAPI oder Node.js NestJS +- **Datenbank:** PostgreSQL +- **Caching:** Redis +- **Datei-Speicher:** S3-kompatibler Object Storage (MinIO oder AWS S3) +- **Containerisierung:** Docker + Kubernetes +- **CI/CD:** GitHub Actions oder GitLab CI + +--- + +## 7. Annahmen + +1. **Multi-Tenant-Architektur:** Jedes Unternehmen (Account) hat vollständig isolierte Daten. Ein Account kann mehrere Benutzer haben. +2. **Keine Integration mit echtem Rentman:** Dies ist ein Standalone-Produkt, kein Sync-Client. +3. **Firmen-eigener Server oder Cloud:** Self-Hosted oder SaaS, keine zwingende Abhängigkeit von externen Diensten außer E-Mail und optional S3. +4. **Keine Echtzeit-Kollaboration** im MVP (kein WebSocket-Sync mehrerer Benutzer im gleichen Formular). Locking-Mechanismus ausreichend. +5. **Buchhaltung:** Kein vollständiges Buchhaltungssystem. Der Klon deckt Angebote, Rechnungen und grundlegende Finanzübersicht ab. DATEV-Export optional. +6. **E-Mail-Versand:** Transaktionale E-Mails (SMTP) und optionale Integration mit SendGrid/Mailgun. +7. **Datei-Uploads:** Maximal 50 MB pro Datei. +8. **Mobile App:** PWA mit Capacitor (nicht nativ), ausreichend für Kamera-Zugriff und Offline-Speicher. +9. **Projektvorlagen:** Kopieren nur die Struktur (Funktionen, Gruppen), nicht konkrete Crew- oder Equipment-Zuweisungen. +10. **Nummernkreise:** pro Account konfigurierbar (Projektnummer, Angebotsnummer, Rechnungsnummer). + +--- + +## 8. Nicht-Ziele (Out of Scope) + +Dieser Abschnitt definiert explizit, was **nicht** Teil des Klons ist (MVP und V1). + +| ID | Nicht-Ziel | Begründung | +|----|-----------|-----------| +| N-01 | Vollständige Buchhaltung / DATEV-Integration | Übersteigt Scope; Fokus auf Angebot/Rechnung | +| N-02 | Personalabrechnung / Lohn | Eigene Domäne, nicht Kern des Verleih-Managements | +| N-03 | GPS-Tracking von Fahrzeugen | Hardware-Abhängigkeit, optional in V2 | +| N-04 | KI-basierte Preisvorschläge | Außerhalb MVP-Scope | +| N-05 | Native iOS/Android Apps | PWA deckt Anforderungen; native Apps optional V2 | +| N-06 | Echtzeit-Chat zwischen Benutzern | Kommunikation erfolgt über externe Kanäle | +| N-07 | Integration mit Miet-ERP-Systemen | Custom-Integrationen pro Kunde → Professional Services | +| N-08 | BIM-/CAD-Integration | Nischen-Anforderung, nicht für MVP | +| N-09 | RFID-Hardware-Integration | Barcode/QR ausreichend; RFID optional | +| N-10 | On-Premise Active Directory / LDAP | OAuth2/SAML für V2 geplant | + +--- + +## 9. Offene Fragen + +Diese Fragen müssen vor Architektur-Beginn geklärt werden. + +| ID | Frage | Relevanz für | Vorgeschlagene Antwort | +|----|-------|-------------|----------------------| +| Q-01 | Welches Tech-Stack wird bevorzugt? (React/FastAPI vs. Vue/Laravel vs. Next.js/Prisma) | Architektur-Entscheidung | React + FastAPI, da API-zentrisch | +| Q-02 | Multi-Tenant auf DB-Ebene (shared DB + tenant_id) oder Schema-Ebene (ein Schema pro Tenant)? | Sicherheit, Skalierung | Shared DB mit tenant_id (einfacher) | +| Q-03 | Soll es ein Self-Service-Onboarding geben (Account registrieren) oder nur Admin-gesteuert? | Benutzerfluss | Self-Service mit Trial-Periode | +| Q-04 | Wie soll die Template-Engine für Angebote/Rechnungen funktionieren? WYSIWYG oder HTML/CSS? | UI-Design, Aufwand | HTML mit Platzhaltern (ähnlich Rentman), Vorschau-Funktion | +| Q-05 | Preis-Modell des Klons? (Open Source, kommerziell, SaaS-Abo) | Geschäftsmodell | Open Source mit kommerzieller SaaS-Option | +| Q-06 | Welche Sprachen werden für MVP benötigt? | i18n-Aufwand | Deutsch + Englisch | +| Q-07 | Barcode-Generierung: Code128, QR, beide? Welches Label-Format? | Druck-Layout | QR-Code primär, Code128 optional. Label: 50×25mm | +| Q-08 | Offline-Sync-Strategie: Last-Write-Wins oder CRDT? | Mobile App | Last-Write-Wins (einfacher, ausreichend für Equipment-Tracking) | + +--- + +## Zusammenfassung + +| Metrik | Wert | +|--------|------| +| **Anzahl User Stories** | 40 | +| **Anzahl Kernentitäten** | 16 | +| **Abgedeckte Module** | 13/13 (100%) | +| **Geschätzter Funktionsumfang vs. Rentman** | ≥90% | +| **Offene Fragen** | 8 | +| **Explizite Nicht-Ziele** | 10 | +| **Bereit für Architektur** | Ja (nach Klärung der offenen Fragen) | diff --git a/.a0/resume.md b/.a0/resume.md new file mode 100644 index 0000000..c77331f --- /dev/null +++ b/.a0/resume.md @@ -0,0 +1,241 @@ +# Resume – Rentman Clone (abgeschlossener Stand) + +> **Letzte Aktualisierung:** 2026-05-31 13:10 +> **Zuständiger Agent:** A0 Software Orchestrator +> **Nächster Agent kann nahtlos fortfahren.** + +--- + +## Projektübersicht +**Ziel:** Vollständiger Nachbau von Rentman.io (Cloud-basierte Vermietungs- und Event-Management-Plattform) mit ≥90 % Funktionsumfang. +**Ansatz:** Projektzentrisch – jedes Modul gehört zu einem Projekt. +**Status:** Implementierungsphase (plan_mode: implementation_allowed). + +--- + +## Bisheriger Fortschritt + +### ✅ Abgeschlossene Phasen (0–2) +| Phase | Tasks | Inhalt | +|-------|-------|--------| +| 0 – Foundation | T001–T003 | Projektstruktur, Docker Compose, FastAPI-Grundgerüst, React-Frontend mit Vite/Tailwind/Router/Zustand | +| 1 – Auth-System | T004–T008 | Account/User/Role-Modelle, JWT-Auth (Login/Register/Refresh), RBAC-Middleware, Admin-API für User & Roles, Login/Register/Admin-UI | +| 2 – Contacts & Tags | T009–T011 | Contact- und Tag-Modelle mit M2M, CRUD-API für Contacts (mit Suche, Typfilter, Tags), Tags-API, Contacts-UI (Liste, Formular, Detailseite) | + +### 🔜 Nächste Phase (3) – Equipment Catalog +| Task | Beschreibung | +|------|-------------| +| T012 | Datenbankmodelle: Equipment, StockLocation, EquipmentGroup (M2M) | +| T013 | Equipment-CRUD-API mit Suche, Filter, Barcode/QR-Generierung, CSV-Import/Export | +| T014 | Equipment-UI: Liste (Grid/Table-Toggle), Detailseite, Formular, Barcode-Druck | + +**Offene Tasks:** T012–T020 (Equipment, Crew, Vehicles), T021+ (Projektmanagement), Finanzen, Scheduling, Reporting, PWA etc. (siehe `todo.md` und `task_graph.json`). + +--- + +## Technologie-Stack + +### Backend (`/backend`) +- **Framework:** FastAPI (Python 3.12+, async) +- **ORM:** SQLAlchemy 2.0 (async engine mit aiosqlite für Dev) +- **Migrationen:** Alembic (render_as_batch für SQLite) +- **Auth:** JWT (python-jose) + Passwort-Hashing (passlib bcrypt) +- **Config:** Pydantic Settings (`.env` / `.env.example`) +- **Datenbank:** SQLite (`backend/data/rentman.db`) für Dev, PostgreSQL 16 für Prod geplant +- **Task Queue:** Celery + Redis (später) +- **Datei-Storage:** MinIO (Docker) + +### Frontend (`/frontend`) +- **Framework:** React 19 + TypeScript +- **Build:** Vite +- **Styling:** Tailwind CSS 4 + Radix UI Primitives +- **State Management:** Zustand (Auth-Store) + TanStack React Query (API-Daten) +- **Routing:** React Router 7 +- **HTTP-Client:** Axios mit Interceptor (Access-Token, Auto-Refresh) +- **Icons:** Lucide React + +### Infrastruktur +- **Docker Compose:** Services: backend, frontend, redis, minio +- **Makefile:** Befehle: `dev`, `backend-dev`, `frontend-dev`, `migrate`, etc. + +--- + +## Projektstruktur +``` +rentman-clone/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI-App, Lifespan, CORS +│ │ ├── core/ +│ │ │ ├── config.py # Settings +│ │ │ └── security.py # JWT + bcrypt +│ │ ├── db/ +│ │ │ ├── base.py # SQLAlchemy Base +│ │ │ └── session.py # AsyncEngine + Session +│ │ ├── models/ # SQLAlchemy-Modelle +│ │ │ ├── account.py +│ │ │ ├── user.py +│ │ │ ├── role.py +│ │ │ ├── contact.py # + Tag M2M +│ │ │ └── tag.py +│ │ ├── schemas/ # Pydantic-Schemas +│ │ │ ├── auth.py +│ │ │ ├── user.py +│ │ │ ├── role.py +│ │ │ └── contact.py +│ │ ├── api/v1/ +│ │ │ ├── router.py # Registriert alle Sub-Router +│ │ │ ├── health.py +│ │ │ ├── auth.py +│ │ │ ├── users.py +│ │ │ ├── roles.py +│ │ │ ├── contacts.py +│ │ │ └── tags.py +│ │ └── api/deps.py # Dependency: get_current_user, require_permission +│ ├── alembic/ +│ │ ├── env.py # Async + Batch +│ │ └── versions/ # Migrationen +│ ├── data/ # SQLite-DB +│ ├── requirements.txt +│ ├── Dockerfile +│ └── .env.example +├── frontend/ +│ ├── src/ +│ │ ├── main.tsx # Einstieg, QueryClient, Router +│ │ ├── App.tsx # Routing +│ │ ├── components/ +│ │ │ ├── AppLayout.tsx # Sidebar, Header, Navigation +│ │ │ ├── ProtectedRoute.tsx +│ │ │ └── ContactForm.tsx +│ │ ├── pages/ +│ │ │ ├── Home.tsx +│ │ │ ├── Login.tsx +│ │ │ ├── Register.tsx +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Users.tsx # Admin +│ │ │ ├── Roles.tsx # Admin +│ │ │ ├── Contacts.tsx +│ │ │ ├── ContactsNew.tsx +│ │ │ ├── ContactsEdit.tsx +│ │ │ └── ContactDetail.tsx +│ │ ├── services/ +│ │ │ └── api.ts # Axios-Client mit Token-Interceptor +│ │ ├── stores/ +│ │ │ └── authStore.ts # Zustand Auth-Store (persistiert) +│ │ └── lib/ +│ │ └── utils.ts # cn()-Funktion +│ ├── package.json +│ ├── vite.config.ts +│ └── Dockerfile +├── docker-compose.yml +├── Makefile +├── README.md +└── .a0/ # Orchestrator-Artefakte + ├── project_state.json + ├── requirements.md + ├── architecture.md + ├── design.md + ├── task_graph.json + ├── decisions.md + ├── todo.md + ├── worklog.md + ├── current_status.md + ├── next_steps.md + ├── known_errors.md + └── resume.md ← DIESE DATEI +``` + +--- + +## Wichtige Code-Patterns & Konventionen + +### Backend +1. **Model → Schema → Router → Frontend:** Dieses Pattern für jedes neue Modul verwenden. +2. **Tenant-Isolation:** Alle Datenbankabfragen müssen `account_id`-Filter enthalten. Der aktuelle User wird aus der JWT extrahiert (`get_current_user`) und liefert `current_user.account_id`. +3. **RBAC:** Jeder Endpunkt mit `require_permission("<resource>:<action>")` schützen. Vorhandene Permissions: `users:read/write/delete`, `roles:read/write`, `contacts:read/write/delete`, `tags:read/write`, `equipment:*` (noch zu vergeben). +4. **UUIDs:** Alle Primärschlüssel sind UUIDs (Python `uuid.uuid4()`). +5. **Migrationen:** Mit `alembic revision --autogenerate` erstellen, `render_as_batch=True` in env.py beachten. Nach Erstellung im `versions/`-Ordner manuell anpassen. +6. **API-Paginierung:** Standard: `limit` und `offset` als Query-Parameter, Antwort mit `items`-Array und `total`-Count. + +### Frontend +1. **Auth-Store:** `authStore` enthält `token`, `user`, `isAuthenticated`. Bei Login/Refresh gespeichert, bei Logout gelöscht. +2. **API-Client:** `services/api.ts` Axios-Instanz mit `baseURL`, Access-Token-Interceptor (fügt `Authorization`-Header hinzu) und 401-Auto-Refresh (ruft `/auth/refresh` auf). +3. **Protected Route:** `<ProtectedRoute permission="...">` kapselt Zugriffsschutz. Umleitet auf `/login` wenn nicht authentifiziert, zeigt Fehler wenn Permission fehlt. +4. **React Query:** Für Datenabrufe `useQuery`, für Mutationen `useMutation` verwenden (z.B. `useMutation({ mutationFn: ... })`). +5. **Tailwind:** Utility-Klassen verwenden, keine eigenen CSS-Dateien. Theme-Farben im `index.css` definiert. +6. **Neue Seiten:** In `App.tsx` Route hinzufügen, ggf. mit ProtectedRoute. Sidebar-Links in `AppLayout.tsx` ergänzen. + +--- + +## Startanweisungen + +### Voraussetzungen +- Docker und Docker Compose installiert +- Python 3.12+, Node.js 22+ + +### Entwicklung starten +```bash +cd /a0/usr/workdir/dev-projects/rentman-clone + +# Abhängigkeiten installieren +make install-backend # pip install -r backend/requirements.txt +make install-frontend # npm install + +# Backend starten (uvicorn auf Port 8000) +make backend-dev + +# Frontend starten (Vite auf Port 5173) +make frontend-dev + +# Oder alles mit Docker: +docker compose up +``` + +### Datenbank-Migrationen +```bash +cd backend +# Neue Migration erzeugen +alembic revision --autogenerate -m "beschreibung" +# Migration anwenden +alembic upgrade head +``` + +### API testen +```bash +# Registrierung +curl -X POST http://localhost:8000/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{"account_name":"Testfirma", "full_name":"Admin", "email":"admin@test.de", "password":"test123"}' + +# Login +curl -X POST http://localhost:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@test.de", "password":"test123"}' + +# Mit Token +curl http://localhost:8000/api/v1/auth/me \ + -H "Authorization: Bearer <access_token>" +``` + +--- + +## Offene Punkte & Bekannte Einschränkungen + +- **Keine automatischen Tests** vorhanden (später hinzufügen). +- **SQLite-Spezifika:** Einige PostgreSQL-Features (z.B. JSON-Felder in SQLite als Text) müssen bei Migration beachtet werden. +- **Image-Upload:** Noch nicht implementiert (Equipment-Bilder, Logos). +- **Frontend-Seiten** für Equipment, Crew, Vehicles, Projects, Finanzen etc. fehlen noch. +- **Auth-Flows** wie Passwort-Reset und E-Mail-Verifikation fehlen. +- **Internationalisierung** (i18n) nicht begonnen (DE+EN geplant). + +--- + +## Nächste Aktion für neuen Agenten +1. **.a0/resume.md** (diese Datei) lesen, dann **.a0/project_state.json**, **.a0/task_graph.json**, **.a0/next_steps.md** und **.a0/requirements.md**. +2. **Plan Mode** prüfen: ist `implementation_allowed`. +3. **Phase 3 starten** mit Task **T012**: Equipment-Datenbankmodelle (models/equipment.py, StockLocation, EquipmentGroup), wie in `design.md` und `architecture.md` beschrieben. +4. Anschließend T013 (API) und T014 (UI). +5. Bei Problemen `known_errors.md` prüfen und ggf. erweitern. +6. Alle Änderungen in `.a0/`-Dateien dokumentieren (worklog, todo, current_status, next_steps). + +**Viel Erfolg! 🚀** diff --git a/.a0/rollback.md b/.a0/rollback.md new file mode 100644 index 0000000..f9cdbaa --- /dev/null +++ b/.a0/rollback.md @@ -0,0 +1,9 @@ +# Rollback + +## Pre-requisites + +## Procedure + +## Verification + +## Emergency Contacts diff --git a/.a0/runbook.md b/.a0/runbook.md new file mode 100644 index 0000000..8891264 --- /dev/null +++ b/.a0/runbook.md @@ -0,0 +1,13 @@ +# Runbook + +## Start + +## Stop + +## Restart + +## Logs + +## Common Issues + +## Monitoring diff --git a/.a0/runtime_report.md b/.a0/runtime_report.md new file mode 100644 index 0000000..41fd96f --- /dev/null +++ b/.a0/runtime_report.md @@ -0,0 +1,15 @@ +# Runtime Report + +## Date + +## Application Status + +## Logs Summary + +## Healthcheck + +## Browser Smoke Test + +## Errors Found + +## Recommendations diff --git a/.a0/task_graph.json b/.a0/task_graph.json new file mode 100644 index 0000000..c6b1b01 --- /dev/null +++ b/.a0/task_graph.json @@ -0,0 +1,482 @@ +[ + { + "id": "T001", + "title": "Initialize project repository and Docker Compose", + "description": "Create project root with backend/ and frontend/ directories. Set up Docker Compose with services: backend (FastAPI), frontend (Vite React), PostgreSQL, Redis, MinIO. Create .env.example with required env vars.", + "dependencies": [], + "estimated_hours": 4, + "owner_agent": "implementation_engineer" + }, + { + "id": "T002", + "title": "Set up FastAPI skeleton with SQLAlchemy and Alembic", + "description": "Initialize FastAPI app with async support, configure SQLAlchemy 2.0 engine and async session, set up Alembic for migrations, create initial migration with Account and User tables.", + "dependencies": ["T001"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T003", + "title": "Set up React SPA with Vite, Tailwind CSS, routing and Zustand", + "description": "Initialize Vite React project with TypeScript, add React Router, Zustand store, React Query, Tailwind CSS with Radix UI primitives. Create basic layout and routing structure.", + "dependencies": ["T001"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T004", + "title": "Create database models for Account, User, Role", + "description": "Implement SQLAlchemy models: Account, User (with password hashing), Role (with permissions JSON). Define relationships and tenant_id on User.", + "dependencies": ["T002"], + "estimated_hours": 4, + "owner_agent": "implementation_engineer" + }, + { + "id": "T005", + "title": "Implement user authentication (login, refresh, register)", + "description": "Build auth endpoints: POST /auth/login (JWT access+refresh tokens), POST /auth/refresh, POST /auth/register (create tenant+admin). Use bcrypt, JWT handling.", + "dependencies": ["T004"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T006", + "title": "Implement RBAC middleware and permission check dependencies", + "description": "Create FastAPI dependency that extracts user from JWT, loads role permissions, and checks required permission. Add tenant filtering (account_id) to all queries.", + "dependencies": ["T005"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T007", + "title": "Create user and role management API (admin)", + "description": "CRUD endpoints for users: GET /users, POST /users, GET/PUT/DELETE /users/{id}. Role management: GET/PUT roles. Invitation workflow with email activation link.", + "dependencies": ["T006"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T008", + "title": "Login and user management UI", + "description": "Build login page, registration page, password reset flow. Admin pages for user list, create/edit user, role matrix editor. Route guards based on auth state.", + "dependencies": ["T003", "T005", "T007"], + "estimated_hours": 12, + "owner_agent": "implementation_engineer" + }, + { + "id": "T009", + "title": "Create database models for Contacts and Tags", + "description": "SQLAlchemy models for Contact (company/person) and Tag. Create Alembic migration.", + "dependencies": ["T004"], + "estimated_hours": 3, + "owner_agent": "implementation_engineer" + }, + { + "id": "T010", + "title": "Implement contacts CRUD API", + "description": "REST endpoints for contacts: GET /contacts (with search/filter), POST /contacts, GET/PUT/DELETE /contacts/{id}. Return associated projects.", + "dependencies": ["T009", "T006"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T011", + "title": "Contacts management UI (list, detail, form)", + "description": "Pages: contact list with search/filter, contact detail page showing linked projects, create/edit form for company/person.", + "dependencies": ["T010"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T012", + "title": "Create database models for Equipment catalog and StockLocation", + "description": "SQLAlchemy models: Equipment (with barcode, qr_code, category, status), StockLocation, and EquipmentGroup (Bundle) with M2M items. Migrations.", + "dependencies": ["T004"], + "estimated_hours": 4, + "owner_agent": "implementation_engineer" + }, + { + "id": "T013", + "title": "Implement Equipment catalog API", + "description": "CRUD for equipment: GET /equipment (search, filter, pagination), POST /equipment, GET/PUT/DELETE /equipment/{id}. Auto-generate barcode/QR code. Batch import/export CSV.", + "dependencies": ["T012", "T006"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T014", + "title": "Equipment catalog UI", + "description": "Equipment list with grid/table toggle, search/filter by category, status, location. Detail page with image, edit form. Barcode/QR code display and print label.", + "dependencies": ["T013"], + "estimated_hours": 12, + "owner_agent": "implementation_engineer" + }, + { + "id": "T015", + "title": "Create database models for Crew and CrewAvailability", + "description": "SQLAlchemy models: Crew (internal/freelancer, skills JSON), CrewAvailability (availability/block periods). Migrations.", + "dependencies": ["T004"], + "estimated_hours": 3, + "owner_agent": "implementation_engineer" + }, + { + "id": "T016", + "title": "Implement Crew catalog and availability API", + "description": "CRUD for crew members, GET /crew/{id}/availability (timeline). Endpoints to manage availability slots.", + "dependencies": ["T015", "T006"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T017", + "title": "Crew management UI", + "description": "Crew list, detail page, edit form. Availability calendar per crew member. Skill tags editor.", + "dependencies": ["T016"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T018", + "title": "Create database models for Vehicles and VehicleAssignment", + "description": "SQLAlchemy models: Vehicle (type, payload, volume), VehicleAssignment (project, period, driver). Migrations.", + "dependencies": ["T004"], + "estimated_hours": 3, + "owner_agent": "implementation_engineer" + }, + { + "id": "T019", + "title": "Implement Vehicle catalog and assignment API", + "description": "CRUD for vehicles, GET /vehicles/{id}/assignments. Endpoint for availability check.", + "dependencies": ["T018", "T006"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T020", + "title": "Vehicle management UI", + "description": "Vehicle list, detail, edit form. Calendar for assignments. Drag-and-drop to project (later).", + "dependencies": ["T019"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T021", + "title": "Create database models for Project hierarchy", + "description": "SQLAlchemy models: Project, Subproject, ProjectFunctionGroup, ProjectFunction (with type enum). Migrations. Define cascades.", + "dependencies": ["T004"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T022", + "title": "Implement Project CRUD API", + "description": "Endpoints: GET/POST /projects (filter, sort, paginate), GET/PUT/DELETE /projects/{id}. Include subproject endpoints, function group and function endpoints within project. Auto-generate project number. Status workflow with history.", + "dependencies": ["T021", "T006", "T010"], + "estimated_hours": 16, + "owner_agent": "implementation_engineer" + }, + { + "id": "T023", + "title": "Project list and detail UI", + "description": "Project list page with advanced filters (status, customer, date range), sortable columns, pagination. Detail page with tabs: Overview, Functions, Equipment, Crew, Vehicles, Financial, Documents. Inline editing for some fields.", + "dependencies": ["T022"], + "estimated_hours": 20, + "owner_agent": "implementation_engineer" + }, + { + "id": "T024", + "title": "Project function group and function editor UI", + "description": "Inside project detail, allow CRUD of function groups and functions (crew/equipment/transport). Drag-and-drop reordering. Time period inheritance from group. Amount, rate fields.", + "dependencies": ["T023"], + "estimated_hours": 14, + "owner_agent": "implementation_engineer" + }, + { + "id": "T025", + "title": "Create database models for ProjectEquipment and ProjectCrew", + "description": "Models: ProjectEquipmentGroup, ProjectEquipment (with equipment link, quantity, price, discount), ProjectCrew (with function link, crew_id, period). Migrations.", + "dependencies": ["T021"], + "estimated_hours": 4, + "owner_agent": "implementation_engineer" + }, + { + "id": "T026", + "title": "Implement project equipment group and items API", + "description": "Endpoints for project equipment groups: CRUD under /projects/{id}/equipmentgroups. Add equipment items to group (select from catalog or free-text). Include availability check.", + "dependencies": ["T025", "T022", "T013"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T027", + "title": "Project equipment management UI", + "description": "Within project, manage equipment groups and items. Search/autocomplete from catalog, set quantity, price, discount. Visual availability indicator. Drag-and-drop to add items (future).", + "dependencies": ["T026"], + "estimated_hours": 12, + "owner_agent": "implementation_engineer" + }, + { + "id": "T028", + "title": "Implement crew assignment API", + "description": "Endpoints for assigning crew to project functions. POST /projects/{id}/functiongroups/{gid}/functions/{fid}/crew (assign crew member). Availability conflict detection.", + "dependencies": ["T025", "T016"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T029", + "title": "Crew assignment UI in project", + "description": "In project crew tab, show crew functions and assigned crew. Search/select crew, show availability conflicts. Drag-and-drop crew onto function.", + "dependencies": ["T028"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T030", + "title": "Implement vehicle assignment API and UI", + "description": "Endpoints to assign vehicles to project (similar to crew). UI under project transport tab.", + "dependencies": ["T025", "T019"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T031", + "title": "Create database models for Quotes, Invoices, Templates", + "description": "SQLAlchemy models: Quote, Invoice, Template. Migrations.", + "dependencies": ["T004"], + "estimated_hours": 3, + "owner_agent": "implementation_engineer" + }, + { + "id": "T032", + "title": "Implement Quote and Invoice API", + "description": "Endpoints: POST /projects/{id}/quotes (generate from project items), GET/PUT quotes. POST invoices from quotes. Status transitions. Number auto-generation.", + "dependencies": ["T031", "T022"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T033", + "title": "Financial documents UI", + "description": "Quote list, create/edit form, PDF preview download. Invoice list with status badges. Payment status tracking. Email sending dialog.", + "dependencies": ["T032"], + "estimated_hours": 14, + "owner_agent": "implementation_engineer" + }, + { + "id": "T034", + "title": "Template engine for quotes/invoices", + "description": "Backend: HTML template with placeholders, fill with project data, render to PDF (WeasyPrint). Admin UI for template CRUD with editor.", + "dependencies": ["T031"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T035", + "title": "Create database models for ProjectRequest", + "description": "Models: ProjectRequest, ProjectRequestEquipment. Migrations.", + "dependencies": ["T004"], + "estimated_hours": 2, + "owner_agent": "implementation_engineer" + }, + { + "id": "T036", + "title": "Implement project request API and conversion", + "description": "Public endpoint POST /requests (without auth). Authenticated endpoints to list, update status, and convert request to project (copy fields, equipment items).", + "dependencies": ["T035", "T022"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T037", + "title": "Project request public form and admin UI", + "description": "Public page with form to submit a project request. Admin page to view requests, convert to project with confirmation dialog.", + "dependencies": ["T036"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T038", + "title": "Create database models for Documents", + "description": "SQLAlchemy model: Document (file metadata). Migrations. Configure file storage (local for dev, S3 for prod).", + "dependencies": ["T004"], + "estimated_hours": 3, + "owner_agent": "implementation_engineer" + }, + { + "id": "T039", + "title": "Implement document upload/download API", + "description": "Endpoints: POST /documents (multipart upload), GET /documents/{id} (download or signed URL), DELETE. Associate with project.", + "dependencies": ["T038", "T006"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T040", + "title": "Document management UI", + "description": "File list within project, drag-and-drop upload, file preview (images, PDFs), delete with confirmation.", + "dependencies": ["T039"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T041", + "title": "Implement scheduling and timeline API", + "description": "Endpoints to fetch project timelines (Gantt data), resource utilization timelines (equipment, crew, vehicles availability overlaps).", + "dependencies": ["T022", "T013", "T016", "T019"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T042", + "title": "Gantt timeline and resource views UI", + "description": "Interactive Gantt chart for projects (horizontal bars, drag-resize to change dates). Resource utilization view with colored blocks. Zoom/pan.", + "dependencies": ["T041"], + "estimated_hours": 18, + "owner_agent": "implementation_engineer" + }, + { + "id": "T043", + "title": "Implement iCal feed and external calendar integration", + "description": "Generate per-user iCal feed URL containing project events. Allow subscribing in Google Calendar etc.", + "dependencies": ["T022"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T044", + "title": "Create Task and TaskAssignment models and API", + "description": "Models for to-do tasks inside projects. CRUD endpoints with status (open, in progress, done), assignee.", + "dependencies": ["T004", "T021"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T045", + "title": "Task management UI", + "description": "Task list in project, personal to-do page, drag to change status, email reminders for due tasks.", + "dependencies": ["T044"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T046", + "title": "Reporting and analytics API", + "description": "Endpoints for financial KPIs, equipment utilization stats, project statistics. Use aggregation queries.", + "dependencies": ["T032", "T013"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T047", + "title": "Dashboard and reports UI", + "description": "Dashboard with configurable widgets (active projects, revenue, equipment utilization, open invoices). Drill-down links. Report pages with charts (using recharts). Export to CSV.", + "dependencies": ["T046"], + "estimated_hours": 16, + "owner_agent": "implementation_engineer" + }, + { + "id": "T048", + "title": "Implement webhook management and dispatch", + "description": "Webhook CRUD for admins. Background task (Celery) to dispatch webhooks on configured events (project created, status changed, invoice created, etc.). Retry logic and log.", + "dependencies": ["T006", "T005"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T049", + "title": "Webhook configuration UI", + "description": "Admin page to manage webhooks: add URL, select events, secret key, test trigger, view delivery logs.", + "dependencies": ["T048"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T050", + "title": "Create audit log model and middleware", + "description": "AuditLog model. Middleware or event hook to record all CUD operations with user, action, entity, details. API to retrieve audit log (admin only).", + "dependencies": ["T004", "T006"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T051", + "title": "Audit log UI", + "description": "Admin page with searchable, filterable audit log table. Show who did what and when.", + "dependencies": ["T050"], + "estimated_hours": 4, + "owner_agent": "implementation_engineer" + }, + { + "id": "T052", + "title": "Internationalization (i18n) setup", + "description": "Set up i18next in frontend, add DE and EN translation files. Backend: serve translations or use frontend-only. Implement language switcher.", + "dependencies": ["T003"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T053", + "title": "PWA (Progressive Web App) configuration and offline support for mobile", + "description": "Add Service Worker with Workbox, manifest.json, offline caching for project data, equipment lists, pack lists. Use IndexedDB for local storage. Implement sync logic.", + "dependencies": ["T003", "T022"], + "estimated_hours": 16, + "owner_agent": "implementation_engineer" + }, + { + "id": "T054", + "title": "Barcode/QR scanning on mobile (PWA)", + "description": "Integrate Capacitor Barcode Scanner plugin. Implement camera scanning UI. On scan: show equipment info and allow status change, packing/unpacking.", + "dependencies": ["T053", "T014"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T055", + "title": "Mobile-specific UI (pack list, availability check)", + "description": "Build mobile-optimized views: pack list with checkboxes (offline capable), quick availability search. Use responsive design.", + "dependencies": ["T053"], + "estimated_hours": 10, + "owner_agent": "implementation_engineer" + }, + { + "id": "T056", + "title": "Backend API documentation (OpenAPI/Swagger)", + "description": "Ensure all endpoints have proper Pydantic schemas, descriptions, and examples. Generate OpenAPI spec. Write developer README.", + "dependencies": ["T002"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T057", + "title": "Integration tests for critical flows", + "description": "Write pytest tests for auth, project workflow, equipment assignment, quote/invoice generation. Use test database (SQLite).", + "dependencies": ["T005", "T022", "T026", "T032"], + "estimated_hours": 16, + "owner_agent": "implementation_engineer" + }, + { + "id": "T058", + "title": "CI/CD pipeline (GitHub Actions)", + "description": "Set up GitHub Actions workflow: run linting, type checks, tests, build Docker images, push to registry. (Optional) deploy to Coolify via webhook.", + "dependencies": ["T001"], + "estimated_hours": 8, + "owner_agent": "implementation_engineer" + }, + { + "id": "T059", + "title": "Production deployment configuration (Coolify)", + "description": "Create Coolify service with Docker Compose, SSL, environment variables, database backup schedule. Document deployment steps.", + "dependencies": ["T001"], + "estimated_hours": 6, + "owner_agent": "implementation_engineer" + }, + { + "id": "T060", + "title": "Performance optimization and final review", + "description": "Add database indexes missing, optimize heavy queries, implement Redis caching for dashboard stats, add pagination to all list endpoints. Final code review and cleanup.", + "dependencies": ["T047", "T041"], + "estimated_hours": 12, + "owner_agent": "implementation_engineer" + } +] diff --git a/.a0/tasks.md b/.a0/tasks.md new file mode 100644 index 0000000..0412f65 --- /dev/null +++ b/.a0/tasks.md @@ -0,0 +1,14 @@ +# Tasks + +## Task List + +| ID | Name | Complexity | Dependencies | Status | +|---|---|---|---|---| + +## Task Details + +### TASK-001: (example) +- **Complexity**: S +- **Dependencies**: none +- **Description**: ... +- **Definition of Done**: ... diff --git a/.a0/test_report.md b/.a0/test_report.md new file mode 100644 index 0000000..83c4d00 --- /dev/null +++ b/.a0/test_report.md @@ -0,0 +1,13 @@ +# Test Report + +## Date + +## Test Commands + +## Results + +## Failures + +## Coverage + +## Known Issues diff --git a/.a0/todo.md b/.a0/todo.md new file mode 100644 index 0000000..be99e79 --- /dev/null +++ b/.a0/todo.md @@ -0,0 +1,39 @@ +# Todo + +## Open +- [x] T012: Create database models for Equipment catalog and StockLocation +- [x] T013: Implement Equipment catalog API +- [ ] T014: Equipment catalog UI (in progress) +- [x] T015: Create database models for Crew and CrewAvailability +- [x] T016: Implement Crew catalog and availability API +- [x] T017: Crew management UI +- [x] T018: Create database models for Vehicles and VehicleAssignment +- [x] T019: Implement Vehicle catalog and assignment API +- [x] T020: Vehicle management UI +- [x] T021: Create database models for Project hierarchy +- [ ] T022: Implement Project CRUD API +- [ ] T023: Project list and detail UI +- [ ] T024: Project function group and function editor UI +- [ ] T025: Create database models for ProjectEquipment and ProjectCrew +- [ ] T026+: Remaining project tasks +- [ ] Test and validate +- [ ] Verify runtime +- [ ] Prepare deployment +- [ ] Release audit + +## In Progress +- (none) + +## Done +- Project initialization +- T001: Initialize project repository and Docker Compose +- T002: Set up FastAPI skeleton with SQLAlchemy and Alembic +- T003: Set up React SPA with Vite, Tailwind CSS, routing and Zustand +- T004: Create database models for Account, User, Role +- T005: Implement user authentication (login, refresh, register) +- T006: Implement RBAC middleware and permission check dependencies +- T007: Create user and role management API (admin) +- T008: Login and user management UI +- T009: Create database models for Contacts and Tags +- T010: Implement contacts CRUD API +- T011: Contacts management UI (list, detail, form) diff --git a/.a0/tool_capabilities.json b/.a0/tool_capabilities.json new file mode 100644 index 0000000..ebfde2e --- /dev/null +++ b/.a0/tool_capabilities.json @@ -0,0 +1,29 @@ +{ + "forgejo": { + "available": false, + "can_create_repo": false, + "can_push": false, + "fallback": "document git steps and continue locally" + }, + "coolify": { + "available": false, + "can_deploy": false, + "fallback": "prepare deploy/coolify.md and manual handoff" + }, + "terminal": { + "available": true, + "can_run_tests": true + }, + "browser": { + "available": false, + "fallback": "write manual browser QA checklist" + }, + "git": { + "available": true, + "can_commit": true + }, + "docker": { + "available": false, + "can_run_containers": false + } +} diff --git a/.a0/worklog.md b/.a0/worklog.md new file mode 100644 index 0000000..4d2b732 --- /dev/null +++ b/.a0/worklog.md @@ -0,0 +1,141 @@ +# Worklog + +## Log + +| Timestamp | Phase | Work Block | Agent | Summary | Status | +|---|---|---|---|---|---| +| 2026-05-31 06:14 | init | 0 | a0_software_orchestrator | Project initialized | completed | +| 2026-05-31 12:32 | 0 | T001 | implementation_engineer | Created project structure, docker-compose.yml, .env.example, Makefile, README.md, .gitignore | completed | +| 2026-05-31 12:32 | 0 | T002 | implementation_engineer | Set up FastAPI app with async SQLAlchemy, Alembic migration (accounts, users), health endpoint | completed | +| 2026-05-31 12:32 | 0 | T003 | implementation_engineer | Initialized React+TS Vite project, Tailwind CSS, React Router, Zustand store, React Query, Radix UI, AppLayout, placeholder pages | completed | +| 2026-05-31 12:48 | 1 | T004 | implementation_engineer | Created Role model, updated User model with FK role_id, added Account.roles relationship, created Alembic migration for roles table, ran migrations | completed | +| 2026-05-31 12:48 | 1 | T005 | implementation_engineer | Implemented JWT security (core/security.py), auth schemas (schemas/auth.py), auth endpoints (POST /auth/register, POST /auth/login, POST /auth/refresh, GET /auth/me) with bcrypt + JWT | completed | +| 2026-05-31 12:48 | 1 | T006 | implementation_engineer | Created FastAPI dependencies (api/deps.py): get_current_user, get_current_active_user, require_permission with HTTPBearer JWT extraction, role permission check, and tenant filtering via account_id | completed | +| 2026-05-31 12:48 | 1 | T007 | implementation_engineer | Implemented user management API (GET/POST /users, GET/PUT/DELETE /users/{id}) and role management API (GET/POST /roles, PUT /roles/{id}) with admin permissions, tenant filtering, pagination | completed | +| 2026-05-31 12:48 | 1 | T008 | implementation_engineer | Completed Login/Register pages with API, ProtectedRoute component, auth-aware routing, admin Users/Roles pages with CRUD, AppLayout with conditional admin links, authStore with localStorage persistence, auto-refresh interceptor | completed | +| 2026-05-31 13:05 | 2 | T009 | implementation_engineer | Created Contact and Tag SQLAlchemy models, contact_tags M2M association table, updated Account model with contacts and tags relationships, generated and applied Alembic migration a5b1e93690ff | completed | +| 2026-05-31 13:05 | 2 | T010 | implementation_engineer | Created Pydantic schemas (schemas/contact.py), contacts CRUD router (api/v1/contacts.py) with search/filter/pagination/permissions, tags router (api/v1/tags.py) for listing/creating tags, registered both routers in api/v1/router.py | completed | +| 2026-05-31 13:05 | 2 | T011 | implementation_engineer | Created Contacts.tsx list page with search/filter/table/pagination, ContactForm.tsx shared component with company/person tabs, billing/shipping addresses, tag selector, ContactsNew.tsx create page, ContactsEdit.tsx edit page, ContactDetail.tsx detail page with info/addresses/notes/tags/projects placeholder, updated App.tsx with contacts routes, updated AppLayout.tsx with AddressBook sidebar link | completed | +## 2026-05-31 - T012: Equipment Catalog Datenbank-Modelle + +**Status**: ✅ Abgeschlossen + +### Erstellt +- `backend/app/models/equipment.py` – Equipment-Modell mit allen Feldern +- `backend/app/models/stock_location.py` – StockLocation-Modell +- `backend/app/models/equipment_group.py` – EquipmentGroup mit M2M-Association-Table +- `backend/app/models/account.py` – um back_populates-Beziehungen erweitert +- `backend/app/models/__init__.py` – um neue Modelle erweitert +- `backend/alembic/versions/da13d44bd483_create_equipment_stocklocation_.py` – Migration generiert und angewandt + +### Nächster Task +**T013**: Equipment Catalog API (CRUD-Endpoints) +## 2026-05-31 - T013: Equipment Catalog API + +**Status**: ✅ Abgeschlossen + +### Erstellt +- – Pydantic-Schemas (CRUD-Requests + Responses mit Location/Supplier-Refs) +- – StockLocation-Schemas +- – EquipmentGroup-Schemas mit Item-Mengen +- – CRUD-Router mit search/filter/pagination/RBAC +- – StockLocation-CRUD +- – EquipmentGroup-CRUD mit Item-Management +- – +3 neue Router registriert + +### Nächster Task +**T014**: Equipment catalog UI (Frontend) +## 2026-05-31 - T014: Equipment Catalog UI + +**Status**: ✅ Abgeschlossen + +### Erstellt +- – Formular mit Name, Kategorie, Status, Standort, Finanz-/Physik-Daten +- – Liste mit Search, Kategorie/Status-Filter, Pagination, RBAC +- – Create-Seite +- – Edit-Seite +- – Detailansicht mit allen Equipment-Feldern +- – Routen für /equipment, /equipment/new, /equipment/:itemId, /equipment/:itemId/edit + +### Nächster Task +**T015**: Crew Datenbank-Modelle +## 2026-05-31 - T015+T016: Crew Datenbank + API + +**Status**: ✅ Abgeschlossen + +### Erstellt T015 +- – Crew + CrewAvailability Modelle +- Alembic-Migration: 14f212e85ab6 (Tabellen: crew, crew_availabilities) + +### Erstellt T016 +- – Pydantic-Schemas +- – Router (Crew CRUD + Availabilities CRUD) +- Router in v1/router.py registriert + +### Nächster Task +**T017**: Crew Frontend (UI) +## 2026-05-31 - T017: Crew Management UI + +**Status**: ✅ Abgeschlossen + +### Erstellt +- +- (Liste) +- +- +- (mit Availabilities-Anzeige) +- – Crew-Routen registriert + +### Nächster Task +**T018-T020**: Vehicles (Modelle → API → UI) +## 2026-05-31 - T018-T020: Vehicles (DB + API + UI) + +**Status**: ✅ Abgeschlossen + +### Erstellt T018 +- – Vehicle + VehicleAssignment Modelle (project_id ohne FK, da Projects später kommt) +- Alembic-Migration: 32b02c118683 +- Account um vehicles-Beziehung erweitert + +### Erstellt T019 +- – Pydantic-Schemas +- – Router (Vehicle CRUD + Assignments) +- Router registriert + +### Erstellt T020 +- +- (Liste) +- +- +- (mit Assignments-Anzeige) +- – Vehicle-Routen registriert + +### Nächster Task +**T022**: Project CRUD API +## 2026-05-31 - T022: Project CRUD API + +**Status**: ✅ Abgeschlossen + +### Erstellt +- `backend/app/schemas/project.py` – Pydantic-Schemas für Project, SubProject, ProjectFunctionGroup, ProjectFunction (Create/Update/Response/ListResponse-Varianten) +- `backend/app/api/v1/projects.py` – Router mit vollständigen CRUD-Endpunkten: + - Project: GET (list mit search/filter/pagination), POST, GET/:id, PUT/:id, DELETE/:id + - SubProject: GET list, POST create, GET/:id, PUT/:id, DELETE/:id (unter /projects/{id}/subprojects) + - ProjectFunctionGroup: GET list, POST create, GET/:id, PUT/:id, DELETE/:id (unter /projects/{id}/function-groups) + - ProjectFunction: GET list, POST create, GET/:id, PUT/:id, DELETE/:id (unter /projects/{id}/function-groups/{fg_id}/functions) +- `backend/app/api/v1/router.py` – Router registriert als projects_router + +### Permission-Checks +- `projects:read` für GET-Endpunkte +- `projects:write` für POST/PUT-Endpunkte +- `projects:delete` für DELETE-Endpunkte + +### Tenant-Isolation +- Alle Endpunkte filtern nach `current_user.account_id` +- SubProject/FunctionGroup/Function-Endpunkte validieren Projekt-Zugehörigkeit via account_id + +### Syntax-Check +- `python -c "from app.api.v1.projects import router; print('OK')"` → OK + +### Nächster Task +**T023**: Project list and detail UI diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7edb8e5 --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# ============================ +# Rentman Clone – Environment Variables +# ============================ +# Copy this file to .env and adjust values for your environment. + +# --- Database --- +# SQLite for local development +DATABASE_URL=sqlite+aiosqlite:///./data/rentman.db +# PostgreSQL for production (uncomment when ready) +# DATABASE_URL=postgresql+asyncpg://rentman:changeme@postgres:5432/rentman + +# --- JWT Authentication --- +JWT_SECRET_KEY=change-me-to-a-random-secret-at-least-32-chars +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_MINUTES=1440 + +# --- Redis --- +REDIS_URL=redis://localhost:6379/0 + +# --- MinIO / S3-compatible Storage --- +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET_NAME=rentman-files +MINIO_SECURE=false + +# --- Application --- +APP_ENV=development +APP_DEBUG=true +APP_NAME=Rentman Clone +API_V1_PREFIX=/api/v1 + +# --- CORS --- +CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + +# --- Coolify / Deployment (optional) --- +# COOLIFY_API_URL=https://coolify.example.com/api/v1 +# COOLIFY_API_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43c90d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.venv/ +venv/ +ENV/ +env/ + +# SQLite +*.db + +# Alembic +backend/alembic/versions/*.pyc + +# Node +frontend/node_modules/ +frontend/dist/ +.npm/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local +.env.*.local + +# Docker +docker-compose.override.yml + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dca86c --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: help dev backend-dev frontend-dev migrate create-migration install-backend install-frontend clean + +help: + @echo "Available commands:" + @echo " make dev - Start all services with Docker Compose" + @echo " make backend-dev - Start backend only with Docker" + @echo " make frontend-dev - Start frontend only with Docker" + @echo " make install-backend - Install backend dependencies" + @echo " make install-frontend - Install frontend dependencies" + @echo " make migrate - Run database migrations" + @echo " make create-migration - Create a new Alembic migration (MSG='message')" + @echo " make clean - Remove Python cache files" + +# Docker-based development +dev: + docker-compose up --build + +backend-dev: + docker-compose up backend --build + +frontend-dev: + docker-compose up frontend --build + +# Local development (without Docker) +install-backend: + cd backend && pip install -r requirements.txt + +install-frontend: + cd frontend && npm install + +migrate: + cd backend && alembic upgrade head + +create-migration: + cd backend && alembic revision --autogenerate -m "$(MSG)" + +clean: + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6b230e --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Rentman Clone + +A full-stack clone of Rentman.io, a project-centric SaaS platform for rental and event industries. + +## Tech Stack + +- **Backend:** FastAPI (Python 3.12+), SQLAlchemy 2.0, SQLite/PostgreSQL, Redis, Celery +- **Frontend:** React 19, TypeScript, Vite, Tailwind CSS, Radix UI, Zustand, React Query +- **Storage:** MinIO (S3-compatible) +- **Deployment:** Docker Compose, Coolify + +## Getting Started + +### Prerequisites + +- Docker & Docker Compose +- Or Python 3.12+, Node.js 20+ + +### Quick Start (Docker) + +```bash +cp .env.example .env +# Edit .env if needed +make dev +``` + +This starts: +- Backend API at http://localhost:8000 (Swagger at /docs) +- Frontend at http://localhost:5173 +- Redis at port 6379 +- MinIO at ports 9000 (API) and 9001 (Console) + +### Local Development (without Docker) + +#### Backend + +```bash +cd backend +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp ../.env.example .env # adjust DATABASE_URL if needed +uvicorn app.main:app --reload +``` + +#### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +### Project Structure + +``` +. +├── backend/ # FastAPI application +│ ├── app/ +│ │ ├── main.py # App entry point +│ │ ├── core/ # Config, security +│ │ ├── db/ # Database setup +│ │ ├── models/ # SQLAlchemy models +│ │ ├── schemas/ # Pydantic schemas +│ │ ├── api/ # API routes +│ │ └── services/ # Business logic +│ ├── alembic/ # Database migrations +│ └── requirements.txt +├── frontend/ # React application +│ ├── src/ +│ │ ├── components/ # Shared UI components +│ │ ├── pages/ # Page components +│ │ ├── stores/ # Zustand stores +│ │ ├── hooks/ # Custom hooks +│ │ └── lib/ # Utilities +│ └── package.json +├── docker-compose.yml +├── Makefile +└── .env.example +``` + +## Environment Variables + +See `.env.example` for all required variables. Key ones: + +| Variable | Description | +|----------|-------------| +| DATABASE_URL | Database connection string (SQLite for dev) | +| JWT_SECRET_KEY | Secret for signing JWT tokens | +| REDIS_URL | Redis connection URL | +| MINIO_ACCESS_KEY / MINIO_SECRET_KEY | MinIO credentials | +| CORS_ORIGINS | Allowed origins for CORS | + +## Available Scripts + +```bash +make dev # Start all services +make install-backend # Install Python dependencies +make install-frontend # Install npm dependencies +make migrate # Run database migrations +make create-migration MSG="description" # Create new migration +make clean # Clean cache files +``` + +## API Documentation + +Once the backend is running, visit: +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## License + +MIT diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f1e809c --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directory for SQLite +RUN mkdir -p /app/data + +# Expose port +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..3700390 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,119 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..0be484b --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,69 @@ +"""Alembic environment configuration for async SQLAlchemy.""" + +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.db.base import Base + +# Import all models so they are registered on Base.metadata +import app.models # noqa: F401 + +# Alembic Config object +config = context.config + +# Set the SQLAlchemy URL from our application settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# Interpret the config file for Python logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Target metadata for autogenerate +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # Required for SQLite ALTER TABLE support + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + """Execute migrations in a synchronous context.""" + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Create async engine and run migrations using run_sync.""" + connectable = create_async_engine(settings.DATABASE_URL) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_async_migrations()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/14f212e85ab6_create_crew_and_crewavailability_tables.py b/backend/alembic/versions/14f212e85ab6_create_crew_and_crewavailability_tables.py new file mode 100644 index 0000000..9ed011a --- /dev/null +++ b/backend/alembic/versions/14f212e85ab6_create_crew_and_crewavailability_tables.py @@ -0,0 +1,58 @@ +"""create_crew_and_crewavailability_tables + +Revision ID: 14f212e85ab6 +Revises: da13d44bd483 +Create Date: 2026-05-31 11:50:59.485426 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '14f212e85ab6' +down_revision: Union[str, None] = 'da13d44bd483' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('crew', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('role_title', sa.String(length=100), nullable=True), + sa.Column('hourly_rate', sa.Float(), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('crew_availabilities', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('crew_id', sa.String(length=36), nullable=False), + sa.Column('start_date', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_date', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['crew_id'], ['crew.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('crew_availabilities') + op.drop_table('crew') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/32b02c118683_create_vehicle_and_vehicleassignment_.py b/backend/alembic/versions/32b02c118683_create_vehicle_and_vehicleassignment_.py new file mode 100644 index 0000000..6942f59 --- /dev/null +++ b/backend/alembic/versions/32b02c118683_create_vehicle_and_vehicleassignment_.py @@ -0,0 +1,64 @@ +"""create_vehicle_and_vehicleassignment_tables + +Revision ID: 32b02c118683 +Revises: 14f212e85ab6 +Create Date: 2026-05-31 12:05:27.261340 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '32b02c118683' +down_revision: Union[str, None] = '14f212e85ab6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('vehicles', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('license_plate', sa.String(length=50), nullable=True), + sa.Column('brand', sa.String(length=100), nullable=True), + sa.Column('model', sa.String(length=100), nullable=True), + sa.Column('year', sa.Integer(), nullable=True), + sa.Column('color', sa.String(length=50), nullable=True), + sa.Column('vehicle_type', sa.String(length=50), nullable=True), + sa.Column('payload_capacity_kg', sa.Float(), nullable=True), + sa.Column('load_volume_m3', sa.Float(), nullable=True), + sa.Column('fuel_type', sa.String(length=30), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('license_plate') + ) + op.create_table('vehicle_assignments', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('vehicle_id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=True), + sa.Column('start_date', sa.DateTime(timezone=True), nullable=False), + sa.Column('end_date', sa.DateTime(timezone=True), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('vehicle_assignments') + op.drop_table('vehicles') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/714cc11a59c4_create_roles_table.py b/backend/alembic/versions/714cc11a59c4_create_roles_table.py new file mode 100644 index 0000000..98b25ef --- /dev/null +++ b/backend/alembic/versions/714cc11a59c4_create_roles_table.py @@ -0,0 +1,44 @@ +"""create_roles_table + +Revision ID: 714cc11a59c4 +Revises: f90ee7806a25 +Create Date: 2026-05-31 10:35:56.717393 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '714cc11a59c4' +down_revision: Union[str, None] = 'f90ee7806a25' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('roles', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.String(length=500), nullable=True), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_foreign_key('fk_users_role_id_roles', 'roles', ['role_id'], ['id'], ondelete='SET NULL') + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_constraint(None, type_='foreignkey') + + op.drop_table('roles') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/a5b1e93690ff_create_contacts_and_tags.py b/backend/alembic/versions/a5b1e93690ff_create_contacts_and_tags.py new file mode 100644 index 0000000..484e649 --- /dev/null +++ b/backend/alembic/versions/a5b1e93690ff_create_contacts_and_tags.py @@ -0,0 +1,75 @@ +"""create_contacts_and_tags + +Revision ID: a5b1e93690ff +Revises: 714cc11a59c4 +Create Date: 2026-05-31 10:58:21.063736 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a5b1e93690ff' +down_revision: Union[str, None] = '714cc11a59c4' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('contacts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('type', sa.String(length=10), nullable=False), + sa.Column('company_name', sa.String(length=255), nullable=True), + sa.Column('first_name', sa.String(length=100), nullable=True), + sa.Column('last_name', sa.String(length=100), nullable=True), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('mobile', sa.String(length=50), nullable=True), + sa.Column('website', sa.String(length=500), nullable=True), + sa.Column('billing_street', sa.String(length=255), nullable=True), + sa.Column('billing_number', sa.String(length=20), nullable=True), + sa.Column('billing_postalcode', sa.String(length=20), nullable=True), + sa.Column('billing_city', sa.String(length=100), nullable=True), + sa.Column('billing_country', sa.String(length=100), nullable=True), + sa.Column('shipping_street', sa.String(length=255), nullable=True), + sa.Column('shipping_number', sa.String(length=20), nullable=True), + sa.Column('shipping_postalcode', sa.String(length=20), nullable=True), + sa.Column('shipping_city', sa.String(length=100), nullable=True), + sa.Column('shipping_country', sa.String(length=100), nullable=True), + sa.Column('tax_number', sa.String(length=50), nullable=True), + sa.Column('note', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('tags', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('color', sa.String(length=7), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('contact_tags', + sa.Column('contact_id', sa.String(length=36), nullable=False), + sa.Column('tag_id', sa.String(length=36), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['contacts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('contact_id', 'tag_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('contact_tags') + op.drop_table('tags') + op.drop_table('contacts') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/da13d44bd483_create_equipment_stocklocation_.py b/backend/alembic/versions/da13d44bd483_create_equipment_stocklocation_.py new file mode 100644 index 0000000..ca9694b --- /dev/null +++ b/backend/alembic/versions/da13d44bd483_create_equipment_stocklocation_.py @@ -0,0 +1,92 @@ +"""create_equipment_stocklocation_equipmentgroup_tables + +Revision ID: da13d44bd483 +Revises: a5b1e93690ff +Create Date: 2026-05-31 11:31:54.796382 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'da13d44bd483' +down_revision: Union[str, None] = 'a5b1e93690ff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stock_locations', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('address', sa.String(length=500), nullable=True), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('equipment', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('brand', sa.String(length=255), nullable=True), + sa.Column('serial_number', sa.String(length=100), nullable=True), + sa.Column('barcode', sa.String(length=100), nullable=True), + sa.Column('qr_code', sa.String(length=500), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('purchase_price', sa.Float(), nullable=True), + sa.Column('current_value', sa.Float(), nullable=True), + sa.Column('weight_kg', sa.Float(), nullable=True), + sa.Column('dimensions', sa.String(length=255), nullable=True), + sa.Column('power_watt', sa.Float(), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('custom_fields', sa.Text(), nullable=True), + sa.Column('location_id', sa.String(length=36), nullable=True), + sa.Column('supplier_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['location_id'], ['stock_locations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['supplier_id'], ['contacts.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('barcode'), + sa.UniqueConstraint('serial_number') + ) + op.create_table('equipment_groups', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('daily_rate', sa.Float(), nullable=True), + sa.Column('default_location_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['default_location_id'], ['stock_locations.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('equipment_group_items', + sa.Column('group_id', sa.String(length=36), nullable=False), + sa.Column('equipment_id', sa.String(length=36), nullable=False), + sa.Column('quantity', sa.Float(), nullable=False), + sa.ForeignKeyConstraint(['equipment_id'], ['equipment.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['group_id'], ['equipment_groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('group_id', 'equipment_id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('equipment_group_items') + op.drop_table('equipment_groups') + op.drop_table('equipment') + op.drop_table('stock_locations') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f5772dd5cd55_create_project_tables.py b/backend/alembic/versions/f5772dd5cd55_create_project_tables.py new file mode 100644 index 0000000..10ea620 --- /dev/null +++ b/backend/alembic/versions/f5772dd5cd55_create_project_tables.py @@ -0,0 +1,79 @@ +"""create_project_tables + +Revision ID: f5772dd5cd55 +Revises: 32b02c118683 +Create Date: 2026-05-31 13:59:30.308076 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f5772dd5cd55' +down_revision: Union[str, None] = '32b02c118683' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('projects', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('budget', sa.Float(), nullable=True), + sa.Column('total_costs', sa.Float(), nullable=False), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('custom_fields', sa.Text(), nullable=True), + sa.Column('custom_field_defs', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_function_groups', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('sub_projects', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('parent_id', sa.String(length=36), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['parent_id'], ['sub_projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_functions', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('function_group_id', sa.String(length=36), nullable=False), + sa.Column('quantity', sa.Float(), nullable=False), + sa.Column('daily_costs', sa.Float(), nullable=False), + sa.Column('total_costs', sa.Float(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['function_group_id'], ['project_function_groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('project_functions') + op.drop_table('sub_projects') + op.drop_table('project_function_groups') + op.drop_table('projects') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/f90ee7806a25_create_accounts_and_users_tables.py b/backend/alembic/versions/f90ee7806a25_create_accounts_and_users_tables.py new file mode 100644 index 0000000..c0a6372 --- /dev/null +++ b/backend/alembic/versions/f90ee7806a25_create_accounts_and_users_tables.py @@ -0,0 +1,50 @@ +"""create_accounts_and_users_tables + +Revision ID: f90ee7806a25 +Revises: +Create Date: 2026-05-31 10:25:47.726322 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f90ee7806a25' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('accounts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('account_id', sa.String(length=36), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role_id', sa.String(length=36), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('users') + op.drop_table('accounts') + # ### end Alembic commands ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..aaf050d --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,99 @@ +"""FastAPI dependencies for authentication, authorization, and tenant filtering.""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, ExpiredSignatureError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from app.core.security import verify_token +from app.db.session import get_async_session +from app.models import User + +security_scheme = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security_scheme), + session: AsyncSession = Depends(get_async_session), +) -> User: + """Extract and validate JWT from Authorization header, return the current user.""" + token = credentials.credentials + try: + payload = verify_token(token) + except ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired", + ) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) + + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + + # Load user with role (and its permissions) eagerly + result = await session.execute( + select(User) + .options(joinedload(User.role)) + .where(User.id == user_id) + ) + user = result.unique().scalars().first() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated", + ) + + return user + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """Return the current user if they are active (redundant check, kept for clarity).""" + if not current_user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated", + ) + return current_user + + +def require_permission(permission: str): + """Factory that returns a dependency checking for a specific permission.""" + + async def permission_checker( + current_user: User = Depends(get_current_user), + ) -> User: + if not current_user.role: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="No role assigned", + ) + + user_permissions: list[str] = current_user.role.permissions or [] + if permission not in user_permissions: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing required permission: {permission}", + ) + + return current_user + + return permission_checker diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..20c98ad --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,193 @@ +"""Authentication endpoints: register, login, refresh, me.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user +from app.core.security import ( + create_access_token, + create_refresh_token, + verify_password, + verify_token, + get_password_hash, +) +from app.db.session import get_async_session +from app.models import Account, User, Role +from app.schemas.auth import ( + RegisterRequest, + LoginRequest, + RefreshRequest, + TokenResponse, + UserInfo, + UserResponse, +) + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def _build_user_info(user: User, role: Role | None = None) -> UserInfo: + """Helper to extract UserInfo from a User model instance.""" + # Fallback to user.role if loaded, else use the provided role + role_obj = role or user.role + return UserInfo( + id=user.id, + email=user.email, + full_name=user.full_name, + account_id=user.account_id, + role_id=user.role_id, + role_name=role_obj.name if role_obj else None, + permissions=role_obj.permissions if role_obj else [], + ) + + +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + body: RegisterRequest, + session: AsyncSession = Depends(get_async_session), +): + """Register a new account with an admin user.""" + # Check if email already exists + result = await session.execute(select(User).where(User.email == body.email)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this email already exists", + ) + + # Check if account name already exists + result = await session.execute(select(Account).where(Account.name == body.account_name)) + if result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An account with this name already exists", + ) + + # Create account + account = Account(name=body.account_name) + session.add(account) + await session.flush() + + # Create admin role + admin_permissions = [ + "projects:read", "projects:write", "projects:delete", + "equipment:read", "equipment:write", "equipment:delete", + "crew:read", "crew:write", "crew:delete", + "vehicles:read", "vehicles:write", "vehicles:delete", + "contacts:read", "contacts:write", "contacts:delete", + "users:read", "users:write", "users:delete", + "roles:read", "roles:write", + ] + admin_role = Role( + account_id=account.id, + name="Admin", + description="Full access to all features", + permissions=admin_permissions, + ) + session.add(admin_role) + await session.flush() + + # Create admin user + user = User( + account_id=account.id, + email=body.email, + full_name=body.full_name, + password_hash=get_password_hash(body.password), + role_id=admin_role.id, + ) + session.add(user) + await session.commit() + await session.refresh(user) + + # Generate tokens + access_token = create_access_token(subject=user.id) + refresh_token = create_refresh_token(subject=user.id) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + user=_build_user_info(user), + ) + + +@router.post("/login", response_model=TokenResponse) +async def login( + body: LoginRequest, + session: AsyncSession = Depends(get_async_session), +): + """Authenticate user with email and password.""" + result = await session.execute(select(User).where(User.email == body.email)) + user = result.scalars().first() + + if not user or not verify_password(body.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid email or password", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account is deactivated", + ) + + access_token = create_access_token(subject=user.id) + refresh_token = create_refresh_token(subject=user.id) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + user=_build_user_info(user), + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh( + body: RefreshRequest, + session: AsyncSession = Depends(get_async_session), +): + """Refresh an access token using a valid refresh token.""" + try: + payload = verify_token(body.refresh_token) + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token type", + ) + user_id = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token payload", + ) + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired refresh token", + ) + + # Verify user exists and is active + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalars().first() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or inactive", + ) + + access_token = create_access_token(subject=user.id) + new_refresh_token = create_refresh_token(subject=user.id) + + return TokenResponse( + access_token=access_token, + refresh_token=new_refresh_token, + user=_build_user_info(user), + ) + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info( + current_user: User = Depends(get_current_user), +): + """Get the current authenticated user's info.""" + return UserResponse.model_validate(current_user) diff --git a/backend/app/api/v1/contacts.py b/backend/app/api/v1/contacts.py new file mode 100644 index 0000000..0cd6d6b --- /dev/null +++ b/backend/app/api/v1/contacts.py @@ -0,0 +1,220 @@ +"""Contacts CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, selectinload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Contact, Tag +from app.models.contact import contact_tags +from app.schemas.contact import ( + ContactCreateRequest, + ContactUpdateRequest, + ContactResponse, + ContactListResponse, +) + +router = APIRouter(prefix="/contacts", tags=["contacts"]) + + +@router.get("", response_model=ContactListResponse) +async def list_contacts( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name, email, phone"), + type: str | None = Query(None, pattern="^(company|person)$", description="Filter by contact type"), + current_user: User = Depends(require_permission("contacts:read")), + session: AsyncSession = Depends(get_async_session), +): + """List contacts with search, filter by type, pagination.""" + account_id = current_user.account_id + + base_q = select(Contact).where(Contact.account_id == account_id) + + # Search across name fields, email, phone + if search: + search_pattern = f"%{search}%" + base_q = base_q.where( + or_( + Contact.company_name.ilike(search_pattern), + Contact.first_name.ilike(search_pattern), + Contact.last_name.ilike(search_pattern), + Contact.email.ilike(search_pattern), + Contact.phone.ilike(search_pattern), + ) + ) + + # Filter by type + if type: + base_q = base_q.where(Contact.type == type) + + # Count total + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + # Fetch page with tags eager-loaded + q = ( + base_q + .options(joinedload(Contact.tags)) + .order_by(Contact.updated_at.desc()) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + contacts = result.unique().scalars().all() + + return ContactListResponse( + items=[ContactResponse.model_validate(c) for c in contacts], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=ContactResponse, status_code=status.HTTP_201_CREATED) +async def create_contact( + body: ContactCreateRequest, + current_user: User = Depends(require_permission("contacts:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new contact.""" + account_id = current_user.account_id + + contact = Contact( + account_id=account_id, + type=body.type, + company_name=body.company_name, + first_name=body.first_name, + last_name=body.last_name, + email=body.email, + phone=body.phone, + mobile=body.mobile, + website=body.website, + billing_street=body.billing_street, + billing_number=body.billing_number, + billing_postalcode=body.billing_postalcode, + billing_city=body.billing_city, + billing_country=body.billing_country, + shipping_street=body.shipping_street, + shipping_number=body.shipping_number, + shipping_postalcode=body.shipping_postalcode, + shipping_city=body.shipping_city, + shipping_country=body.shipping_country, + tax_number=body.tax_number, + note=body.note, + ) + + if body.tag_ids: + # Fetch tags that belong to this tenant (or could be global? We'll enforce tenant scope) + tag_result = await session.execute( + select(Tag).where( + Tag.id.in_(body.tag_ids), + Tag.account_id == account_id, + ) + ) + contact.tags = tag_result.scalars().all() + + session.add(contact) + await session.commit() + await session.refresh(contact) + + # Reload with relationships + await session.execute( + select(Contact) + .options(joinedload(Contact.tags)) + .where(Contact.id == contact.id) + ) + await session.refresh(contact) + + return ContactResponse.model_validate(contact) + + +@router.get("/{contact_id}", response_model=ContactResponse) +async def get_contact( + contact_id: str, + current_user: User = Depends(require_permission("contacts:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific contact with tags.""" + account_id = current_user.account_id + result = await session.execute( + select(Contact) + .options(joinedload(Contact.tags)) + .where(Contact.id == contact_id, Contact.account_id == account_id) + ) + contact = result.unique().scalars().first() + if not contact: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found", + ) + return ContactResponse.model_validate(contact) + + +@router.put("/{contact_id}", response_model=ContactResponse) +async def update_contact( + contact_id: str, + body: ContactUpdateRequest, + current_user: User = Depends(require_permission("contacts:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update an existing contact.""" + account_id = current_user.account_id + result = await session.execute( + select(Contact) + .options(joinedload(Contact.tags)) + .where(Contact.id == contact_id, Contact.account_id == account_id) + ) + contact = result.unique().scalars().first() + if not contact: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found", + ) + + # Update fields if provided + update_data = body.model_dump(exclude_unset=True, exclude={"tag_ids"}) + for field, value in update_data.items(): + setattr(contact, field, value) + + # Handle tag_ids separately: if provided, replace the tag associations + if body.tag_ids is not None: + tag_result = await session.execute( + select(Tag).where( + Tag.id.in_(body.tag_ids), + Tag.account_id == account_id, + ) + ) + contact.tags = tag_result.scalars().all() + + await session.commit() + await session.refresh(contact) + + return ContactResponse.model_validate(contact) + + +@router.delete("/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_contact( + contact_id: str, + current_user: User = Depends(require_permission("contacts:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Hard-delete a contact.""" + account_id = current_user.account_id + result = await session.execute( + select(Contact).where( + Contact.id == contact_id, Contact.account_id == account_id + ) + ) + contact = result.scalars().first() + if not contact: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Contact not found", + ) + + await session.delete(contact) + await session.commit() + return None diff --git a/backend/app/api/v1/crew.py b/backend/app/api/v1/crew.py new file mode 100644 index 0000000..55d3f79 --- /dev/null +++ b/backend/app/api/v1/crew.py @@ -0,0 +1,269 @@ +"""Crew CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, selectinload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Crew, CrewAvailability +from app.schemas.crew import ( + CrewCreateRequest, + CrewUpdateRequest, + CrewResponse, + CrewListResponse, + CrewAvailabilityCreateRequest, + CrewAvailabilityUpdateRequest, + CrewAvailabilityResponse, + CrewAvailabilityListResponse, +) + +router = APIRouter(prefix="/crew", tags=["crew"]) +avail_router = APIRouter(prefix="/crew-availabilities", tags=["crew-availabilities"]) + +# ===== Crew Endpoints ===== + +@router.get("", response_model=CrewListResponse) +async def list_crew( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name, email, role"), + is_active: bool | None = Query(None, description="Filter by active status"), + current_user: User = Depends(require_permission("crew:read")), + session: AsyncSession = Depends(get_async_session), +): + """List crew members with search, filter, pagination.""" + account_id = current_user.account_id + base_q = select(Crew).where(Crew.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where( + or_( + Crew.first_name.ilike(pattern), + Crew.last_name.ilike(pattern), + Crew.email.ilike(pattern), + Crew.role_title.ilike(pattern), + ) + ) + + if is_active is not None: + base_q = base_q.where(Crew.is_active == is_active) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = ( + base_q + .options(selectinload(Crew.availabilities)) + .order_by(Crew.last_name, Crew.first_name) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + items = result.unique().scalars().all() + + return CrewListResponse( + items=[CrewResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=CrewResponse, status_code=status.HTTP_201_CREATED) +async def create_crew( + body: CrewCreateRequest, + current_user: User = Depends(require_permission("crew:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new crew member.""" + account_id = current_user.account_id + member = Crew(account_id=account_id, **body.model_dump()) + session.add(member) + await session.commit() + await session.refresh(member) + return CrewResponse.model_validate(member) + + +@router.get("/{member_id}", response_model=CrewResponse) +async def get_crew( + member_id: str, + current_user: User = Depends(require_permission("crew:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific crew member with availabilities.""" + account_id = current_user.account_id + result = await session.execute( + select(Crew) + .options(selectinload(Crew.availabilities)) + .where(Crew.id == member_id, Crew.account_id == account_id) + ) + member = result.unique().scalars().first() + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found") + return CrewResponse.model_validate(member) + + +@router.put("/{member_id}", response_model=CrewResponse) +async def update_crew( + member_id: str, + body: CrewUpdateRequest, + current_user: User = Depends(require_permission("crew:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a crew member.""" + account_id = current_user.account_id + result = await session.execute( + select(Crew) + .options(selectinload(Crew.availabilities)) + .where(Crew.id == member_id, Crew.account_id == account_id) + ) + member = result.unique().scalars().first() + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(member, field, value) + + await session.commit() + await session.refresh(member) + return CrewResponse.model_validate(member) + + +@router.delete("/{member_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_crew( + member_id: str, + current_user: User = Depends(require_permission("crew:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a crew member.""" + account_id = current_user.account_id + result = await session.execute( + select(Crew).where(Crew.id == member_id, Crew.account_id == account_id) + ) + member = result.scalars().first() + if not member: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found") + + await session.delete(member) + await session.commit() + return None + + +# ===== CrewAvailability Endpoints ===== + +@avail_router.get("", response_model=CrewAvailabilityListResponse) +async def list_availabilities( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + crew_id: str | None = Query(None, description="Filter by crew member"), + status: str | None = Query(None, description="Filter by status"), + current_user: User = Depends(require_permission("crew:read")), + session: AsyncSession = Depends(get_async_session), +): + """List availabilities with filters.""" + account_id = current_user.account_id + base_q = ( + select(CrewAvailability) + .join(Crew) + .where(Crew.account_id == account_id) + ) + + if crew_id: + base_q = base_q.where(CrewAvailability.crew_id == crew_id) + if status: + base_q = base_q.where(CrewAvailability.status == status) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.order_by(CrewAvailability.start_date.desc()).offset((page - 1) * size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + + return CrewAvailabilityListResponse( + items=[CrewAvailabilityResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@avail_router.post("", response_model=CrewAvailabilityResponse, status_code=status.HTTP_201_CREATED) +async def create_availability( + body: CrewAvailabilityCreateRequest, + current_user: User = Depends(require_permission("crew:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new availability entry.""" + # Verify crew member belongs to this tenant + crew_result = await session.execute( + select(Crew).where( + Crew.id == body.crew_id, + Crew.account_id == current_user.account_id, + ) + ) + if not crew_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Crew member not found") + + avail = CrewAvailability(**body.model_dump()) + session.add(avail) + await session.commit() + await session.refresh(avail) + return CrewAvailabilityResponse.model_validate(avail) + + +@avail_router.put("/{avail_id}", response_model=CrewAvailabilityResponse) +async def update_availability( + avail_id: str, + body: CrewAvailabilityUpdateRequest, + current_user: User = Depends(require_permission("crew:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update an availability entry.""" + result = await session.execute( + select(CrewAvailability) + .join(Crew) + .where( + CrewAvailability.id == avail_id, + Crew.account_id == current_user.account_id, + ) + ) + avail = result.scalars().first() + if not avail: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Availability not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(avail, field, value) + + await session.commit() + await session.refresh(avail) + return CrewAvailabilityResponse.model_validate(avail) + + +@avail_router.delete("/{avail_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_availability( + avail_id: str, + current_user: User = Depends(require_permission("crew:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete an availability entry.""" + result = await session.execute( + select(CrewAvailability) + .join(Crew) + .where( + CrewAvailability.id == avail_id, + Crew.account_id == current_user.account_id, + ) + ) + avail = result.scalars().first() + if not avail: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Availability not found") + + await session.delete(avail) + await session.commit() + return None diff --git a/backend/app/api/v1/equipment.py b/backend/app/api/v1/equipment.py new file mode 100644 index 0000000..d0bdfd9 --- /dev/null +++ b/backend/app/api/v1/equipment.py @@ -0,0 +1,193 @@ +"""Equipment CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Equipment, StockLocation, Contact +from app.schemas.equipment import ( + EquipmentCreateRequest, + EquipmentUpdateRequest, + EquipmentResponse, + EquipmentListResponse, + LocationRef, + SupplierRef, +) + +router = APIRouter(prefix="/equipment", tags=["equipment"]) + + +@router.get("", response_model=EquipmentListResponse) +async def list_equipment( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name, brand, serial_number"), + category: str | None = Query(None, description="Filter by category"), + status: str | None = Query(None, description="Filter by status"), + location_id: str | None = Query(None, description="Filter by location"), + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """List equipment with search, filters, pagination.""" + account_id = current_user.account_id + + base_q = select(Equipment).where(Equipment.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where( + or_( + Equipment.name.ilike(pattern), + Equipment.brand.ilike(pattern), + Equipment.serial_number.ilike(pattern), + Equipment.barcode.ilike(pattern), + ) + ) + + if category: + base_q = base_q.where(Equipment.category == category) + if status: + base_q = base_q.where(Equipment.status == status) + if location_id: + base_q = base_q.where(Equipment.location_id == location_id) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = ( + base_q + .options(joinedload(Equipment.location), joinedload(Equipment.supplier)) + .order_by(Equipment.updated_at.desc()) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + items = result.unique().scalars().all() + + return EquipmentListResponse( + items=[EquipmentResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=EquipmentResponse, status_code=status.HTTP_201_CREATED) +async def create_equipment( + body: EquipmentCreateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new equipment item.""" + account_id = current_user.account_id + + # Validate location if provided + if body.location_id: + loc_result = await session.execute( + select(StockLocation).where( + StockLocation.id == body.location_id, + StockLocation.account_id == account_id, + ) + ) + if not loc_result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Stock location not found", + ) + + # Validate supplier if provided + if body.supplier_id: + sup_result = await session.execute( + select(Contact).where( + Contact.id == body.supplier_id, + Contact.account_id == account_id, + ) + ) + if not sup_result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Supplier (contact) not found", + ) + + item = Equipment(account_id=account_id, **body.model_dump()) + session.add(item) + await session.commit() + await session.refresh(item) + + await session.execute( + select(Equipment) + .options(joinedload(Equipment.location), joinedload(Equipment.supplier)) + .where(Equipment.id == item.id) + ) + await session.refresh(item) + + return EquipmentResponse.model_validate(item) + + +@router.get("/{item_id}", response_model=EquipmentResponse) +async def get_equipment( + item_id: str, + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific equipment item.""" + account_id = current_user.account_id + result = await session.execute( + select(Equipment) + .options(joinedload(Equipment.location), joinedload(Equipment.supplier)) + .where(Equipment.id == item_id, Equipment.account_id == account_id) + ) + item = result.unique().scalars().first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") + return EquipmentResponse.model_validate(item) + + +@router.put("/{item_id}", response_model=EquipmentResponse) +async def update_equipment( + item_id: str, + body: EquipmentUpdateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update an existing equipment item.""" + account_id = current_user.account_id + result = await session.execute( + select(Equipment) + .options(joinedload(Equipment.location), joinedload(Equipment.supplier)) + .where(Equipment.id == item_id, Equipment.account_id == account_id) + ) + item = result.unique().scalars().first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(item, field, value) + + await session.commit() + await session.refresh(item) + return EquipmentResponse.model_validate(item) + + +@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_equipment( + item_id: str, + current_user: User = Depends(require_permission("equipment:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete an equipment item.""" + account_id = current_user.account_id + result = await session.execute( + select(Equipment).where(Equipment.id == item_id, Equipment.account_id == account_id) + ) + item = result.scalars().first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found") + + await session.delete(item) + await session.commit() + return None diff --git a/backend/app/api/v1/equipment_groups.py b/backend/app/api/v1/equipment_groups.py new file mode 100644 index 0000000..c1b2a1c --- /dev/null +++ b/backend/app/api/v1/equipment_groups.py @@ -0,0 +1,208 @@ +"""EquipmentGroup (Bundle) CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload, selectinload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, EquipmentGroup, Equipment, StockLocation +from app.models.equipment_group import equipment_group_items +from app.schemas.equipment_group import ( + EquipmentGroupCreateRequest, + EquipmentGroupUpdateRequest, + EquipmentGroupResponse, + EquipmentGroupListResponse, + EquipmentGroupItem as EquipmentGroupItemSchema, +) +from app.schemas.equipment import LocationRef + +router = APIRouter(prefix="/equipment-groups", tags=["equipment-groups"]) + + +def _load_items(group: EquipmentGroup) -> list[dict]: + """Build items list from M2M relationship.""" + # The items relationship loads Equipment objects; we need quantity from the association + # We'll use a separate query approach in the endpoints. + return [] + + +@router.get("", response_model=EquipmentGroupListResponse) +async def list_equipment_groups( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name"), + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """List equipment groups with search and pagination.""" + account_id = current_user.account_id + base_q = select(EquipmentGroup).where(EquipmentGroup.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where(EquipmentGroup.name.ilike(pattern)) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = ( + base_q + .options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items)) + .order_by(EquipmentGroup.name) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + groups = result.unique().scalars().all() + + return EquipmentGroupListResponse( + items=[EquipmentGroupResponse.model_validate(g) for g in groups], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=EquipmentGroupResponse, status_code=status.HTTP_201_CREATED) +async def create_equipment_group( + body: EquipmentGroupCreateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new equipment group (bundle).""" + account_id = current_user.account_id + + group = EquipmentGroup( + account_id=account_id, + name=body.name, + description=body.description, + daily_rate=body.daily_rate, + default_location_id=body.default_location_id, + ) + + if body.items: + equipment_ids = [i.equipment_id for i in body.items] + eq_result = await session.execute( + select(Equipment).where( + Equipment.id.in_(equipment_ids), + Equipment.account_id == account_id, + ) + ) + items = eq_result.scalars().all() + group.items = items + # Store quantities via the association table directly + for item_schema in body.items: + await session.execute( + equipment_group_items.update() + .where( + equipment_group_items.c.group_id == group.id, + equipment_group_items.c.equipment_id == item_schema.equipment_id, + ) + .values(quantity=item_schema.quantity) + ) + + session.add(group) + await session.commit() + await session.refresh(group) + + # Reload with relationships + await session.execute( + select(EquipmentGroup) + .options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items)) + .where(EquipmentGroup.id == group.id) + ) + await session.refresh(group) + + return EquipmentGroupResponse.model_validate(group) + + +@router.get("/{group_id}", response_model=EquipmentGroupResponse) +async def get_equipment_group( + group_id: str, + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific equipment group with items.""" + account_id = current_user.account_id + result = await session.execute( + select(EquipmentGroup) + .options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items)) + .where(EquipmentGroup.id == group_id, EquipmentGroup.account_id == account_id) + ) + group = result.unique().scalars().first() + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found") + return EquipmentGroupResponse.model_validate(group) + + +@router.put("/{group_id}", response_model=EquipmentGroupResponse) +async def update_equipment_group( + group_id: str, + body: EquipmentGroupUpdateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update an equipment group.""" + account_id = current_user.account_id + result = await session.execute( + select(EquipmentGroup) + .options(joinedload(EquipmentGroup.default_location), selectinload(EquipmentGroup.items)) + .where(EquipmentGroup.id == group_id, EquipmentGroup.account_id == account_id) + ) + group = result.unique().scalars().first() + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found") + + update_data = body.model_dump(exclude_unset=True, exclude={"items"}) + for field, value in update_data.items(): + setattr(group, field, value) + + # Handle items replacement + if body.items is not None: + equipment_ids = [i.equipment_id for i in body.items] + eq_result = await session.execute( + select(Equipment).where( + Equipment.id.in_(equipment_ids), + Equipment.account_id == account_id, + ) + ) + group.items = eq_result.scalars().all() + # Update quantities + for item_schema in body.items: + await session.execute( + equipment_group_items.update() + .where( + equipment_group_items.c.group_id == group.id, + equipment_group_items.c.equipment_id == item_schema.equipment_id, + ) + .values(quantity=item_schema.quantity) + ) + + await session.commit() + await session.refresh(group) + return EquipmentGroupResponse.model_validate(group) + + +@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_equipment_group( + group_id: str, + current_user: User = Depends(require_permission("equipment:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete an equipment group.""" + account_id = current_user.account_id + result = await session.execute( + select(EquipmentGroup).where( + EquipmentGroup.id == group_id, + EquipmentGroup.account_id == account_id, + ) + ) + group = result.scalars().first() + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment group not found") + + await session.delete(group) + await session.commit() + return None diff --git a/backend/app/api/v1/health.py b/backend/app/api/v1/health.py new file mode 100644 index 0000000..a7e7572 --- /dev/null +++ b/backend/app/api/v1/health.py @@ -0,0 +1,29 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text + +from app.db.session import get_async_session + +router = APIRouter() + + +@router.get("/health") +async def health_check(session: AsyncSession = Depends(get_async_session)): + """ + Health check endpoint. + Returns application status and database connectivity. + """ + try: + # Test database connection + await session.execute(text("SELECT 1")) + db_status = "connected" + except Exception as e: + db_status = f"error: {str(e)}" + + return { + "status": "ok", + "app": "Rentman Clone", + "database": db_status, + } diff --git a/backend/app/api/v1/projects.py b/backend/app/api/v1/projects.py new file mode 100644 index 0000000..5b79998 --- /dev/null +++ b/backend/app/api/v1/projects.py @@ -0,0 +1,674 @@ +"""Project CRUD endpoints including sub-projects, function groups, and functions.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Project, SubProject, ProjectFunctionGroup, ProjectFunction +from app.schemas.project import ( + ProjectCreateRequest, + ProjectUpdateRequest, + ProjectResponse, + ProjectListResponse, + SubProjectCreateRequest, + SubProjectUpdateRequest, + SubProjectResponse, + SubProjectListResponse, + ProjectFunctionGroupCreateRequest, + ProjectFunctionGroupUpdateRequest, + ProjectFunctionGroupResponse, + ProjectFunctionGroupListResponse, + ProjectFunctionCreateRequest, + ProjectFunctionUpdateRequest, + ProjectFunctionResponse, + ProjectFunctionListResponse, +) + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +# ===== Project Endpoints ===== + + +@router.get("", response_model=ProjectListResponse) +async def list_projects( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in project name"), + status: str | None = Query(None, description="Filter by status (draft, confirmed, in_progress, completed, cancelled)"), + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """List projects with search, filters, pagination.""" + account_id = current_user.account_id + + base_q = select(Project).where(Project.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where(Project.name.ilike(pattern)) + + if status: + base_q = base_q.where(Project.status == status) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = ( + base_q + .order_by(Project.updated_at.desc()) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + items = result.scalars().all() + + return ProjectListResponse( + items=[ProjectResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=ProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_project( + body: ProjectCreateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new project.""" + account_id = current_user.account_id + project = Project(account_id=account_id, **body.model_dump()) + session.add(project) + await session.commit() + await session.refresh(project) + return ProjectResponse.model_validate(project) + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + project_id: str, + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific project.""" + account_id = current_user.account_id + result = await session.execute( + select(Project).where(Project.id == project_id, Project.account_id == account_id) + ) + project = result.scalars().first() + if not project: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + return ProjectResponse.model_validate(project) + + +@router.put("/{project_id}", response_model=ProjectResponse) +async def update_project( + project_id: str, + body: ProjectUpdateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update an existing project.""" + account_id = current_user.account_id + result = await session.execute( + select(Project).where(Project.id == project_id, Project.account_id == account_id) + ) + project = result.scalars().first() + if not project: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(project, field, value) + + await session.commit() + await session.refresh(project) + return ProjectResponse.model_validate(project) + + +@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project( + project_id: str, + current_user: User = Depends(require_permission("projects:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a project.""" + account_id = current_user.account_id + result = await session.execute( + select(Project).where(Project.id == project_id, Project.account_id == account_id) + ) + project = result.scalars().first() + if not project: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + await session.delete(project) + await session.commit() + return None + + +# ===== SubProject Endpoints ===== + + +@router.get("/{project_id}/subprojects", response_model=SubProjectListResponse) +async def list_subprojects( + project_id: str, + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """List subprojects for a project.""" + account_id = current_user.account_id + + # Verify project ownership + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + base_q = select(SubProject).where(SubProject.project_id == project_id) + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.order_by(SubProject.sort_order).offset((page - 1) * size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + + return SubProjectListResponse( + items=[SubProjectResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("/{project_id}/subprojects", response_model=SubProjectResponse, status_code=status.HTTP_201_CREATED) +async def create_subproject( + project_id: str, + body: SubProjectCreateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a subproject within a project.""" + account_id = current_user.account_id + + # Verify project ownership + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + # Verify parent subproject if provided + if body.parent_id: + parent_result = await session.execute( + select(SubProject.id).where( + SubProject.id == body.parent_id, + SubProject.project_id == project_id, + ) + ) + if not parent_result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Parent subproject not found", + ) + + sub = SubProject(project_id=project_id, **body.model_dump()) + session.add(sub) + await session.commit() + await session.refresh(sub) + return SubProjectResponse.model_validate(sub) + + +@router.get("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse) +async def get_subproject( + project_id: str, + sub_id: str, + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific subproject.""" + account_id = current_user.account_id + + # Verify project ownership + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id) + ) + sub = result.scalars().first() + if not sub: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found") + return SubProjectResponse.model_validate(sub) + + +@router.put("/{project_id}/subprojects/{sub_id}", response_model=SubProjectResponse) +async def update_subproject( + project_id: str, + sub_id: str, + body: SubProjectUpdateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a subproject.""" + account_id = current_user.account_id + + # Verify project ownership + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id) + ) + sub = result.scalars().first() + if not sub: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(sub, field, value) + + await session.commit() + await session.refresh(sub) + return SubProjectResponse.model_validate(sub) + + +@router.delete("/{project_id}/subprojects/{sub_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_subproject( + project_id: str, + sub_id: str, + current_user: User = Depends(require_permission("projects:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a subproject.""" + account_id = current_user.account_id + + # Verify project ownership + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(SubProject).where(SubProject.id == sub_id, SubProject.project_id == project_id) + ) + sub = result.scalars().first() + if not sub: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="SubProject not found") + + await session.delete(sub) + await session.commit() + return None + + +# ===== ProjectFunctionGroup Endpoints ===== + + +@router.get("/{project_id}/function-groups", response_model=ProjectFunctionGroupListResponse) +async def list_function_groups( + project_id: str, + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """List function groups for a project.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + base_q = select(ProjectFunctionGroup).where(ProjectFunctionGroup.project_id == project_id) + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.order_by(ProjectFunctionGroup.sort_order).offset((page - 1) * size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + + return ProjectFunctionGroupListResponse( + items=[ProjectFunctionGroupResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("/{project_id}/function-groups", response_model=ProjectFunctionGroupResponse, status_code=status.HTTP_201_CREATED) +async def create_function_group( + project_id: str, + body: ProjectFunctionGroupCreateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a function group within a project.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + fg = ProjectFunctionGroup(project_id=project_id, **body.model_dump()) + session.add(fg) + await session.commit() + await session.refresh(fg) + return ProjectFunctionGroupResponse.model_validate(fg) + + +@router.get("/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse) +async def get_function_group( + project_id: str, + fg_id: str, + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific function group.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(ProjectFunctionGroup).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + fg = result.scalars().first() + if not fg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + return ProjectFunctionGroupResponse.model_validate(fg) + + +@router.put("/{project_id}/function-groups/{fg_id}", response_model=ProjectFunctionGroupResponse) +async def update_function_group( + project_id: str, + fg_id: str, + body: ProjectFunctionGroupUpdateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a function group.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(ProjectFunctionGroup).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + fg = result.scalars().first() + if not fg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(fg, field, value) + + await session.commit() + await session.refresh(fg) + return ProjectFunctionGroupResponse.model_validate(fg) + + +@router.delete("/{project_id}/function-groups/{fg_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_function_group( + project_id: str, + fg_id: str, + current_user: User = Depends(require_permission("projects:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a function group.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + result = await session.execute( + select(ProjectFunctionGroup).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + fg = result.scalars().first() + if not fg: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + await session.delete(fg) + await session.commit() + return None + + +# ===== ProjectFunction Endpoints ===== + + +@router.get("/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionListResponse) +async def list_project_functions( + project_id: str, + fg_id: str, + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """List functions within a function group.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + # Verify function group belongs to project + fg_result = await session.execute( + select(ProjectFunctionGroup.id).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + if not fg_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + base_q = select(ProjectFunction).where(ProjectFunction.function_group_id == fg_id) + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.order_by(ProjectFunction.sort_order).offset((page - 1) * size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + + return ProjectFunctionListResponse( + items=[ProjectFunctionResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("/{project_id}/function-groups/{fg_id}/functions", response_model=ProjectFunctionResponse, status_code=status.HTTP_201_CREATED) +async def create_project_function( + project_id: str, + fg_id: str, + body: ProjectFunctionCreateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a function within a function group.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + fg_result = await session.execute( + select(ProjectFunctionGroup.id).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + if not fg_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + func_obj = ProjectFunction(function_group_id=fg_id, **body.model_dump()) + session.add(func_obj) + await session.commit() + await session.refresh(func_obj) + return ProjectFunctionResponse.model_validate(func_obj) + + +@router.get("/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse) +async def get_project_function( + project_id: str, + fg_id: str, + func_id: str, + current_user: User = Depends(require_permission("projects:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific project function.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + fg_result = await session.execute( + select(ProjectFunctionGroup.id).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + if not fg_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + result = await session.execute( + select(ProjectFunction).where( + ProjectFunction.id == func_id, + ProjectFunction.function_group_id == fg_id, + ) + ) + func_obj = result.scalars().first() + if not func_obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") + return ProjectFunctionResponse.model_validate(func_obj) + + +@router.put("/{project_id}/function-groups/{fg_id}/functions/{func_id}", response_model=ProjectFunctionResponse) +async def update_project_function( + project_id: str, + fg_id: str, + func_id: str, + body: ProjectFunctionUpdateRequest, + current_user: User = Depends(require_permission("projects:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a project function.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + fg_result = await session.execute( + select(ProjectFunctionGroup.id).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + if not fg_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + result = await session.execute( + select(ProjectFunction).where( + ProjectFunction.id == func_id, + ProjectFunction.function_group_id == fg_id, + ) + ) + func_obj = result.scalars().first() + if not func_obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(func_obj, field, value) + + await session.commit() + await session.refresh(func_obj) + return ProjectFunctionResponse.model_validate(func_obj) + + +@router.delete("/{project_id}/function-groups/{fg_id}/functions/{func_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_project_function( + project_id: str, + fg_id: str, + func_id: str, + current_user: User = Depends(require_permission("projects:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a project function.""" + account_id = current_user.account_id + + proj_result = await session.execute( + select(Project.id).where(Project.id == project_id, Project.account_id == account_id) + ) + if not proj_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found") + + fg_result = await session.execute( + select(ProjectFunctionGroup.id).where( + ProjectFunctionGroup.id == fg_id, + ProjectFunctionGroup.project_id == project_id, + ) + ) + if not fg_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function group not found") + + result = await session.execute( + select(ProjectFunction).where( + ProjectFunction.id == func_id, + ProjectFunction.function_group_id == fg_id, + ) + ) + func_obj = result.scalars().first() + if not func_obj: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Function not found") + + await session.delete(func_obj) + await session.commit() + return None diff --git a/backend/app/api/v1/roles.py b/backend/app/api/v1/roles.py new file mode 100644 index 0000000..27aedf7 --- /dev/null +++ b/backend/app/api/v1/roles.py @@ -0,0 +1,88 @@ +"""Role management endpoints (admin).""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Role +from app.schemas.role import RoleCreateRequest, RoleUpdateRequest, RoleResponse + +router = APIRouter(prefix="/roles", tags=["roles"]) + + +@router.get("", response_model=list[RoleResponse]) +async def list_roles( + current_user: User = Depends(require_permission("roles:read")), + session: AsyncSession = Depends(get_async_session), +): + """List all roles within the current account.""" + account_id = current_user.account_id + result = await session.execute( + select(Role).where(Role.account_id == account_id).order_by(Role.name) + ) + roles = result.scalars().all() + return [RoleResponse.model_validate(r) for r in roles] + + +@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED) +async def create_role( + body: RoleCreateRequest, + current_user: User = Depends(require_permission("roles:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new role within the current account.""" + account_id = current_user.account_id + + # Check name uniqueness within account + existing = await session.execute( + select(Role).where(Role.account_id == account_id, Role.name == body.name) + ) + if existing.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A role with this name already exists in your account", + ) + + role = Role( + account_id=account_id, + name=body.name, + description=body.description, + permissions=body.permissions, + ) + session.add(role) + await session.commit() + await session.refresh(role) + return RoleResponse.model_validate(role) + + +@router.put("/{role_id}", response_model=RoleResponse) +async def update_role( + role_id: str, + body: RoleUpdateRequest, + current_user: User = Depends(require_permission("roles:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a role's permissions, name, or description.""" + account_id = current_user.account_id + result = await session.execute( + select(Role).where(Role.id == role_id, Role.account_id == account_id) + ) + role = result.scalars().first() + if not role: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Role not found", + ) + + if body.name is not None: + role.name = body.name + if body.description is not None: + role.description = body.description + if body.permissions is not None: + role.permissions = body.permissions + + await session.commit() + await session.refresh(role) + return RoleResponse.model_validate(role) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..fb59dad --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,33 @@ +"""API v1 router – aggregates all v1 endpoint routers.""" + +from fastapi import APIRouter + +from app.api.v1.health import router as health_router +from app.api.v1.auth import router as auth_router +from app.api.v1.users import router as users_router +from app.api.v1.roles import router as roles_router +from app.api.v1.contacts import router as contacts_router +from app.api.v1.tags import router as tags_router +from app.api.v1.equipment import router as equipment_router +from app.api.v1.stock_locations import router as stock_locations_router +from app.api.v1.equipment_groups import router as equipment_groups_router +from app.api.v1.crew import router as crew_router, avail_router as crew_avail_router +from app.api.v1.vehicles import router as vehicles_router, assign_router as vehicle_assign_router +from app.api.v1.projects import router as projects_router + +api_v1_router = APIRouter() + +api_v1_router.include_router(health_router, tags=["health"]) +api_v1_router.include_router(auth_router, tags=["auth"]) +api_v1_router.include_router(users_router, tags=["users"]) +api_v1_router.include_router(roles_router, tags=["roles"]) +api_v1_router.include_router(contacts_router, tags=["contacts"]) +api_v1_router.include_router(tags_router, tags=["tags"]) +api_v1_router.include_router(equipment_router, tags=["equipment"]) +api_v1_router.include_router(stock_locations_router, tags=["stock-locations"]) +api_v1_router.include_router(equipment_groups_router, tags=["equipment-groups"]) +api_v1_router.include_router(crew_router, tags=["crew"]) +api_v1_router.include_router(crew_avail_router, tags=["crew-availabilities"]) +api_v1_router.include_router(vehicles_router, tags=["vehicles"]) +api_v1_router.include_router(vehicle_assign_router, tags=["vehicle-assignments"]) +api_v1_router.include_router(projects_router, tags=["projects"]) diff --git a/backend/app/api/v1/stock_locations.py b/backend/app/api/v1/stock_locations.py new file mode 100644 index 0000000..a5b2033 --- /dev/null +++ b/backend/app/api/v1/stock_locations.py @@ -0,0 +1,142 @@ +"""StockLocation CRUD endpoints.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, StockLocation +from app.schemas.stock_location import ( + StockLocationCreateRequest, + StockLocationUpdateRequest, + StockLocationResponse, + StockLocationListResponse, +) + +router = APIRouter(prefix="/stock-locations", tags=["stock-locations"]) + + +@router.get("", response_model=StockLocationListResponse) +async def list_stock_locations( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name"), + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """List stock locations with search and pagination.""" + account_id = current_user.account_id + base_q = select(StockLocation).where(StockLocation.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where(StockLocation.name.ilike(pattern)) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.order_by(StockLocation.name).offset((page - 1) * size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + + return StockLocationListResponse( + items=[StockLocationResponse.model_validate(i) for i in items], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=StockLocationResponse, status_code=status.HTTP_201_CREATED) +async def create_stock_location( + body: StockLocationCreateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new stock location.""" + account_id = current_user.account_id + + # If this is set as default, unset others + if body.is_default: + await session.execute( + select(StockLocation) + .where(StockLocation.account_id == account_id, StockLocation.is_default == True) # noqa: E712 + ) + + loc = StockLocation(account_id=account_id, **body.model_dump()) + session.add(loc) + await session.commit() + await session.refresh(loc) + return StockLocationResponse.model_validate(loc) + + +@router.get("/{location_id}", response_model=StockLocationResponse) +async def get_stock_location( + location_id: str, + current_user: User = Depends(require_permission("equipment:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific stock location.""" + account_id = current_user.account_id + result = await session.execute( + select(StockLocation).where( + StockLocation.id == location_id, + StockLocation.account_id == account_id, + ) + ) + loc = result.scalars().first() + if not loc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found") + return StockLocationResponse.model_validate(loc) + + +@router.put("/{location_id}", response_model=StockLocationResponse) +async def update_stock_location( + location_id: str, + body: StockLocationUpdateRequest, + current_user: User = Depends(require_permission("equipment:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a stock location.""" + account_id = current_user.account_id + result = await session.execute( + select(StockLocation).where( + StockLocation.id == location_id, + StockLocation.account_id == account_id, + ) + ) + loc = result.scalars().first() + if not loc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(loc, field, value) + + await session.commit() + await session.refresh(loc) + return StockLocationResponse.model_validate(loc) + + +@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_stock_location( + location_id: str, + current_user: User = Depends(require_permission("equipment:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Delete a stock location.""" + account_id = current_user.account_id + result = await session.execute( + select(StockLocation).where( + StockLocation.id == location_id, + StockLocation.account_id == account_id, + ) + ) + loc = result.scalars().first() + if not loc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Stock location not found") + + await session.delete(loc) + await session.commit() + return None diff --git a/backend/app/api/v1/tags.py b/backend/app/api/v1/tags.py new file mode 100644 index 0000000..8154341 --- /dev/null +++ b/backend/app/api/v1/tags.py @@ -0,0 +1,59 @@ +"""Tags endpoints for listing and creating tags within a tenant.""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Tag +from app.schemas.contact import TagResponse +from pydantic import BaseModel, Field + + +class TagCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + color: str | None = Field(None, max_length=7) + + +router = APIRouter(prefix="/tags", tags=["tags"]) + + +@router.get("", response_model=list[TagResponse]) +async def list_tags( + current_user: User = Depends(require_permission("contacts:read")), + session: AsyncSession = Depends(get_async_session), +): + """List all tags within the current account.""" + account_id = current_user.account_id + result = await session.execute( + select(Tag).where(Tag.account_id == account_id).order_by(Tag.name) + ) + tags = result.scalars().all() + return [TagResponse.model_validate(t) for t in tags] + + +@router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED) +async def create_tag( + body: TagCreateRequest, + current_user: User = Depends(require_permission("contacts:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new tag within the current account.""" + account_id = current_user.account_id + + # Check for duplicate name + existing = await session.execute( + select(Tag).where(Tag.account_id == account_id, Tag.name == body.name) + ) + if existing.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A tag with this name already exists", + ) + + tag = Tag(account_id=account_id, name=body.name, color=body.color) + session.add(tag) + await session.commit() + await session.refresh(tag) + return TagResponse.model_validate(tag) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..5c81774 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,161 @@ +"""User management endpoints (admin).""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_current_user, require_permission +from app.core.security import get_password_hash +from app.db.session import get_async_session +from app.models import User +from app.schemas.user import UserCreateRequest, UserUpdateRequest, UserResponse, UserListResponse + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("", response_model=UserListResponse) +async def list_users( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + current_user: User = Depends(require_permission("users:read")), + session: AsyncSession = Depends(get_async_session), +): + """List all users within the current account.""" + account_id = current_user.account_id + + # Count total + count_q = select(func.count(User.id)).where(User.account_id == account_id) + total = (await session.execute(count_q)).scalar() or 0 + + # Fetch page + q = ( + select(User) + .where(User.account_id == account_id) + .order_by(User.created_at.desc()) + .offset((page - 1) * size) + .limit(size) + ) + result = await session.execute(q) + users = result.scalars().all() + + return UserListResponse( + items=[UserResponse.model_validate(u) for u in users], + total=total, + page=page, + size=size, + ) + + +@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + body: UserCreateRequest, + current_user: User = Depends(require_permission("users:write")), + session: AsyncSession = Depends(get_async_session), +): + """Create a new user within the current account.""" + account_id = current_user.account_id + + # Check email uniqueness + existing = await session.execute(select(User).where(User.email == body.email)) + if existing.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A user with this email already exists", + ) + + # If role_id is provided, verify it belongs to same account (optional) + # (We'll skip role validation for now; can be added later) + + user = User( + account_id=account_id, + email=body.email, + full_name=body.full_name, + password_hash=get_password_hash(body.password), + role_id=body.role_id, + ) + session.add(user) + await session.commit() + await session.refresh(user) + + return UserResponse.model_validate(user) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: str, + current_user: User = Depends(require_permission("users:read")), + session: AsyncSession = Depends(get_async_session), +): + """Get a specific user by ID.""" + account_id = current_user.account_id + result = await session.execute( + select(User).where(User.id == user_id, User.account_id == account_id) + ) + user = result.scalars().first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return UserResponse.model_validate(user) + + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: str, + body: UserUpdateRequest, + current_user: User = Depends(require_permission("users:write")), + session: AsyncSession = Depends(get_async_session), +): + """Update a user's role, active status, or name.""" + account_id = current_user.account_id + result = await session.execute( + select(User).where(User.id == user_id, User.account_id == account_id) + ) + user = result.scalars().first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + if body.full_name is not None: + user.full_name = body.full_name + if body.role_id is not None: + user.role_id = body.role_id + if body.is_active is not None: + user.is_active = body.is_active + + await session.commit() + await session.refresh(user) + return UserResponse.model_validate(user) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: str, + current_user: User = Depends(require_permission("users:delete")), + session: AsyncSession = Depends(get_async_session), +): + """Soft-delete (deactivate) a user. Does not actually delete.""" + account_id = current_user.account_id + result = await session.execute( + select(User).where(User.id == user_id, User.account_id == account_id) + ) + user = result.scalars().first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + # Prevent self-deactivation + if user.id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="You cannot deactivate your own account", + ) + + user.is_active = False + await session.commit() + return None diff --git a/backend/app/api/v1/vehicles.py b/backend/app/api/v1/vehicles.py new file mode 100644 index 0000000..93e8fa4 --- /dev/null +++ b/backend/app/api/v1/vehicles.py @@ -0,0 +1,208 @@ +"""Vehicle CRUD endpoints including assignments.""" + +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.api.deps import get_current_user, require_permission +from app.db.session import get_async_session +from app.models import User, Vehicle, VehicleAssignment +from app.schemas.vehicle import ( + VehicleCreateRequest, + VehicleUpdateRequest, + VehicleResponse, + VehicleListResponse, + VehicleAssignmentCreateRequest, + VehicleAssignmentUpdateRequest, + VehicleAssignmentResponse, + VehicleAssignmentListResponse, +) + +router = APIRouter(prefix="/vehicles", tags=["vehicles"]) +assign_router = APIRouter(prefix="/vehicle-assignments", tags=["vehicle-assignments"]) + + +# ===== Vehicle Endpoints ===== + +@router.get("", response_model=VehicleListResponse) +async def list_vehicles( + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), + search: str | None = Query(None, description="Search in name, plate, brand"), + vehicle_type: str | None = Query(None), + is_active: bool | None = Query(None), + current_user: User = Depends(require_permission("vehicles:read")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + base_q = select(Vehicle).where(Vehicle.account_id == account_id) + + if search: + pattern = f"%{search}%" + base_q = base_q.where( + or_(Vehicle.name.ilike(pattern), Vehicle.license_plate.ilike(pattern), + Vehicle.brand.ilike(pattern), Vehicle.model.ilike(pattern)) + ) + if vehicle_type: + base_q = base_q.where(Vehicle.vehicle_type == vehicle_type) + if is_active is not None: + base_q = base_q.where(Vehicle.is_active == is_active) + + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + + q = base_q.options(selectinload(Vehicle.assignments)).order_by(Vehicle.name).offset((page-1)*size).limit(size) + result = await session.execute(q) + items = result.unique().scalars().all() + + return VehicleListResponse(items=[VehicleResponse.model_validate(i) for i in items], total=total, page=page, size=size) + + +@router.post("", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED) +async def create_vehicle( + body: VehicleCreateRequest, + current_user: User = Depends(require_permission("vehicles:write")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + vehicle = Vehicle(account_id=account_id, **body.model_dump()) + session.add(vehicle) + await session.commit() + await session.refresh(vehicle) + return VehicleResponse.model_validate(vehicle) + + +@router.get("/{vehicle_id}", response_model=VehicleResponse) +async def get_vehicle( + vehicle_id: str, + current_user: User = Depends(require_permission("vehicles:read")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + result = await session.execute( + select(Vehicle).options(selectinload(Vehicle.assignments)) + .where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id) + ) + vehicle = result.unique().scalars().first() + if not vehicle: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found") + return VehicleResponse.model_validate(vehicle) + + +@router.put("/{vehicle_id}", response_model=VehicleResponse) +async def update_vehicle( + vehicle_id: str, + body: VehicleUpdateRequest, + current_user: User = Depends(require_permission("vehicles:write")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + result = await session.execute( + select(Vehicle).options(selectinload(Vehicle.assignments)) + .where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id) + ) + vehicle = result.unique().scalars().first() + if not vehicle: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found") + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(vehicle, field, value) + await session.commit() + await session.refresh(vehicle) + return VehicleResponse.model_validate(vehicle) + + +@router.delete("/{vehicle_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_vehicle( + vehicle_id: str, + current_user: User = Depends(require_permission("vehicles:delete")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + result = await session.execute(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.account_id == account_id)) + vehicle = result.scalars().first() + if not vehicle: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found") + await session.delete(vehicle) + await session.commit() + return None + + +# ===== VehicleAssignment Endpoints ===== + +@assign_router.get("", response_model=VehicleAssignmentListResponse) +async def list_assignments( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + vehicle_id: str | None = Query(None), + status: str | None = Query(None), + current_user: User = Depends(require_permission("vehicles:read")), + session: AsyncSession = Depends(get_async_session), +): + account_id = current_user.account_id + base_q = select(VehicleAssignment).join(Vehicle).where(Vehicle.account_id == account_id) + if vehicle_id: + base_q = base_q.where(VehicleAssignment.vehicle_id == vehicle_id) + if status: + base_q = base_q.where(VehicleAssignment.status == status) + count_q = select(func.count()).select_from(base_q.subquery()) + total = (await session.execute(count_q)).scalar() or 0 + q = base_q.order_by(VehicleAssignment.start_date.desc()).offset((page-1)*size).limit(size) + result = await session.execute(q) + items = result.scalars().all() + return VehicleAssignmentListResponse(items=[VehicleAssignmentResponse.model_validate(i) for i in items], total=total, page=page, size=size) + + +@assign_router.post("", response_model=VehicleAssignmentResponse, status_code=status.HTTP_201_CREATED) +async def create_assignment( + body: VehicleAssignmentCreateRequest, + current_user: User = Depends(require_permission("vehicles:write")), + session: AsyncSession = Depends(get_async_session), +): + v_result = await session.execute(select(Vehicle).where(Vehicle.id == body.vehicle_id, Vehicle.account_id == current_user.account_id)) + if not v_result.scalars().first(): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Vehicle not found") + assign = VehicleAssignment(**body.model_dump()) + session.add(assign) + await session.commit() + await session.refresh(assign) + return VehicleAssignmentResponse.model_validate(assign) + + +@assign_router.put("/{assign_id}", response_model=VehicleAssignmentResponse) +async def update_assignment( + assign_id: str, + body: VehicleAssignmentUpdateRequest, + current_user: User = Depends(require_permission("vehicles:write")), + session: AsyncSession = Depends(get_async_session), +): + result = await session.execute( + select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id) + ) + assign = result.scalars().first() + if not assign: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found") + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(assign, field, value) + await session.commit() + await session.refresh(assign) + return VehicleAssignmentResponse.model_validate(assign) + + +@assign_router.delete("/{assign_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_assignment( + assign_id: str, + current_user: User = Depends(require_permission("vehicles:delete")), + session: AsyncSession = Depends(get_async_session), +): + result = await session.execute( + select(VehicleAssignment).join(Vehicle).where(VehicleAssignment.id == assign_id, Vehicle.account_id == current_user.account_id) + ) + assign = result.scalars().first() + if not assign: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Assignment not found") + await session.delete(assign) + await session.commit() + return None diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..69c9a84 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,50 @@ +"""Application configuration using pydantic-settings.""" + +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Application + APP_NAME: str = "Rentman Clone" + APP_ENV: str = "development" + APP_DEBUG: bool = True + API_V1_PREFIX: str = "/api/v1" + + # Database + DATABASE_URL: str = "sqlite+aiosqlite:///./data/rentman.db" + + # JWT Authentication + JWT_SECRET_KEY: str = "change-me-to-a-random-secret-at-least-32-chars" + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_MINUTES: int = 1440 + + # Redis + REDIS_URL: str = "redis://localhost:6379/0" + + # MinIO + MINIO_ENDPOINT: str = "localhost:9000" + MINIO_ACCESS_KEY: str = "minioadmin" + MINIO_SECRET_KEY: str = "minioadmin" + MINIO_BUCKET_NAME: str = "rentman-files" + MINIO_SECURE: bool = False + + # CORS + CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000" + + @property + def cors_origin_list(self) -> list[str]: + """Return CORS origins as a list.""" + return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()] + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True, + } + + +settings = Settings() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..8a145c0 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,46 @@ +"""Security helpers: password hashing and JWT token management.""" + +from datetime import datetime, timedelta, timezone +from typing import Any + +from jose import jwt +from passlib.context import CryptContext + +from app.core.config import settings + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plaintext password against a bcrypt hash.""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """Hash a password using bcrypt.""" + return pwd_context.hash(password) + + +def create_access_token(subject: str | Any, expires_delta: timedelta | None = None) -> str: + """Create a short-lived JWT access token.""" + if expires_delta is None: + expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + now = datetime.now(timezone.utc) + expire = now + expires_delta + to_encode = {"exp": expire, "sub": str(subject), "iat": now, "type": "access"} + return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def create_refresh_token(subject: str | Any, expires_delta: timedelta | None = None) -> str: + """Create a long-lived JWT refresh token.""" + if expires_delta is None: + expires_delta = timedelta(minutes=settings.REFRESH_TOKEN_EXPIRE_MINUTES) + now = datetime.now(timezone.utc) + expire = now + expires_delta + to_encode = {"exp": expire, "sub": str(subject), "iat": now, "type": "refresh"} + return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + +def verify_token(token: str) -> dict: + """Decode and verify a JWT token. Returns the payload dict.""" + return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/db/base.py b/backend/app/db/base.py new file mode 100644 index 0000000..daf9cdb --- /dev/null +++ b/backend/app/db/base.py @@ -0,0 +1,8 @@ +"""SQLAlchemy declarative base.""" + +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + """Base class for all database models.""" + pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..f4a4d19 --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,31 @@ +"""Async SQLAlchemy engine and session factory.""" + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.core.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.APP_DEBUG, + poolclass=NullPool, # SQLite doesn't support connection pooling across threads +) + +AsyncSessionFactory = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_async_session() -> AsyncSession: + """Dependency that provides an async database session.""" + async with AsyncSessionFactory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a8ea03a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,49 @@ +"""FastAPI application entry point.""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.db.base import Base +from app.db.session import engine +from app.api.v1.router import api_v1_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan handler.""" + # On startup: create tables if they don't exist (for dev convenience) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # On shutdown: dispose engine + await engine.dispose() + + +app = FastAPI( + title=settings.APP_NAME, + version="0.1.0", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan, +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API v1 router +app.include_router(api_v1_router, prefix=settings.API_V1_PREFIX) + + +@app.get("/") +async def root(): + """Root endpoint.""" + return {"message": "Rentman Clone API", "version": "0.1.0"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..5c08cb3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,32 @@ +"""Database models.""" + +from app.models.account import Account +from app.models.user import User +from app.models.role import Role +from app.models.contact import Contact +from app.models.tag import Tag +from app.models.equipment import Equipment +from app.models.stock_location import StockLocation +from app.models.equipment_group import EquipmentGroup +from app.models.crew import Crew, CrewAvailability +from app.models.vehicle import Vehicle, VehicleAssignment +from app.models.project import Project, SubProject, ProjectFunctionGroup, ProjectFunction + +__all__ = [ + "Account", + "User", + "Role", + "Contact", + "Tag", + "Equipment", + "StockLocation", + "EquipmentGroup", + "Crew", + "CrewAvailability", + "Vehicle", + "VehicleAssignment", + "Project", + "SubProject", + "ProjectFunctionGroup", + "ProjectFunction", +] diff --git a/backend/app/models/account.py b/backend/app/models/account.py new file mode 100644 index 0000000..4160078 --- /dev/null +++ b/backend/app/models/account.py @@ -0,0 +1,34 @@ +"""Account (Tenant) model.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Account(Base): + """Represents a tenant/company account.""" + + __tablename__ = "accounts" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + # Relationships + users: Mapped[list["User"]] = relationship("User", back_populates="account") + roles: Mapped[list["Role"]] = relationship("Role", back_populates="account") + contacts: Mapped[list["Contact"]] = relationship("Contact", back_populates="account") + tags: Mapped[list["Tag"]] = relationship("Tag", back_populates="account") + equipment: Mapped[list["Equipment"]] = relationship("Equipment", back_populates="account") + stock_locations: Mapped[list["StockLocation"]] = relationship("StockLocation", back_populates="account") + equipment_groups: Mapped[list["EquipmentGroup"]] = relationship("EquipmentGroup", back_populates="account") + crew_members: Mapped[list["Crew"]] = relationship("Crew", back_populates="account") + projects: Mapped[list["Project"]] = relationship("Project", back_populates="account") diff --git a/backend/app/models/contact.py b/backend/app/models/contact.py new file mode 100644 index 0000000..b55e898 --- /dev/null +++ b/backend/app/models/contact.py @@ -0,0 +1,85 @@ +"""Contact model for companies and persons.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Table, Column +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +# Many-to-many association table: contacts ↔ tags +contact_tags = Table( + "contact_tags", + Base.metadata, + Column( + "contact_id", + String(36), + ForeignKey("contacts.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "tag_id", + String(36), + ForeignKey("tags.id", ondelete="CASCADE"), + primary_key=True, + ), +) + + +class Contact(Base): + """Represents a contact (company or person) within a tenant account.""" + + __tablename__ = "contacts" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + type: Mapped[str] = mapped_column( + String(10), nullable=False + ) # 'company' or 'person' + company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + website: Mapped[str | None] = mapped_column(String(500), nullable=True) + + # Billing address + billing_street: Mapped[str | None] = mapped_column(String(255), nullable=True) + billing_number: Mapped[str | None] = mapped_column(String(20), nullable=True) + billing_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True) + billing_city: Mapped[str | None] = mapped_column(String(100), nullable=True) + billing_country: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Shipping address + shipping_street: Mapped[str | None] = mapped_column(String(255), nullable=True) + shipping_number: Mapped[str | None] = mapped_column(String(20), nullable=True) + shipping_postalcode: Mapped[str | None] = mapped_column(String(20), nullable=True) + shipping_city: Mapped[str | None] = mapped_column(String(100), nullable=True) + shipping_country: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Additional fields from design.md + tax_number: Mapped[str | None] = mapped_column(String(50), nullable=True) + note: Mapped[str | None] = mapped_column(String, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="contacts") + tags: Mapped[list["Tag"]] = relationship( + "Tag", secondary=contact_tags, back_populates="contacts" + ) + # projects_as_customer relationship will be added in Phase 3 when Project model is created diff --git a/backend/app/models/crew.py b/backend/app/models/crew.py new file mode 100644 index 0000000..cb8b510 --- /dev/null +++ b/backend/app/models/crew.py @@ -0,0 +1,88 @@ +"""Crew and CrewAvailability models for managing personnel.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Crew(Base): + """Represents a crew member within a tenant account.""" + + __tablename__ = "crew" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + first_name: Mapped[str] = mapped_column(String(100), nullable=False) + last_name: Mapped[str] = mapped_column(String(100), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + role_title: Mapped[str | None] = mapped_column(String(100), nullable=True) + hourly_rate: Mapped[float | None] = mapped_column(Float, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="crew_members") + availabilities: Mapped[list["CrewAvailability"]] = relationship( + "CrewAvailability", back_populates="crew_member", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"<Crew {self.first_name} {self.last_name}>" + + +class CrewAvailability(Base): + """Tracks crew availability time periods.""" + + __tablename__ = "crew_availabilities" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + crew_id: Mapped[str] = mapped_column( + String(36), ForeignKey("crew.id", ondelete="CASCADE"), nullable=False + ) + start_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + end_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="available" + ) # available, booked, unavailable + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + crew_member: Mapped["Crew"] = relationship("Crew", back_populates="availabilities") + + def __repr__(self) -> str: + return f"<CrewAvailability {self.crew_id}: {self.start_date}-{self.end_date}>" diff --git a/backend/app/models/equipment.py b/backend/app/models/equipment.py new file mode 100644 index 0000000..166b675 --- /dev/null +++ b/backend/app/models/equipment.py @@ -0,0 +1,70 @@ +"""Equipment model for the rental inventory catalog.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Float, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Equipment(Base): + """Represents a piece of rental equipment within a tenant account.""" + + __tablename__ = "equipment" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + brand: Mapped[str | None] = mapped_column(String(255), nullable=True) + serial_number: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True) + barcode: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True) + qr_code: Mapped[str | None] = mapped_column(String(500), nullable=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="available" + ) # available, rented, maintenance, retired + purchase_price: Mapped[float | None] = mapped_column(Float, nullable=True) + current_value: Mapped[float | None] = mapped_column(Float, nullable=True) + weight_kg: Mapped[float | None] = mapped_column(Float, nullable=True) + dimensions: Mapped[str | None] = mapped_column(String(255), nullable=True) + power_watt: Mapped[float | None] = mapped_column(Float, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + custom_fields: Mapped[str | None] = mapped_column( + Text, nullable=True + ) # JSON string for flexible custom fields + + # Location reference + location_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("stock_locations.id", ondelete="SET NULL"), nullable=True + ) + + # Supplier reference (optional contact) + supplier_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("contacts.id", ondelete="SET NULL"), nullable=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="equipment") + location: Mapped["StockLocation"] = relationship( + "StockLocation", back_populates="equipment_items" + ) + supplier: Mapped["Contact"] = relationship("Contact", foreign_keys=[supplier_id]) + + def __repr__(self) -> str: + return f"<Equipment {self.name} ({self.status})>" diff --git a/backend/app/models/equipment_group.py b/backend/app/models/equipment_group.py new file mode 100644 index 0000000..b2ee56f --- /dev/null +++ b/backend/app/models/equipment_group.py @@ -0,0 +1,74 @@ +"""EquipmentGroup (Bundle) model for grouping equipment items.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Table, Column +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + +# Many-to-many association table: equipment_groups ↔ equipment +equipment_group_items = Table( + "equipment_group_items", + Base.metadata, + Column( + "group_id", + String(36), + ForeignKey("equipment_groups.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "equipment_id", + String(36), + ForeignKey("equipment.id", ondelete="CASCADE"), + primary_key=True, + ), + Column( + "quantity", + Float, + nullable=False, + default=1.0, + ), +) + + +class EquipmentGroup(Base): + """Represents a bundle/group of equipment items.""" + + __tablename__ = "equipment_groups" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + daily_rate: Mapped[float | None] = mapped_column(Float, nullable=True) + default_location_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("stock_locations.id", ondelete="SET NULL"), nullable=True + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="equipment_groups") + default_location: Mapped["StockLocation"] = relationship( + "StockLocation", back_populates="equipment_groups" + ) + items: Mapped[list["Equipment"]] = relationship( + "Equipment", secondary=equipment_group_items, backref="groups" + ) + + def __repr__(self) -> str: + return f"<EquipmentGroup {self.name}>" diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..2e2bebe --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,149 @@ +"""Project models for event/rental project management.""" + +import uuid +from datetime import datetime +from typing import Optional + +from sqlalchemy import String, DateTime, ForeignKey, func, Float, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Project(Base): + """Represents a rental/event project within a tenant account.""" + + __tablename__ = "projects" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="draft" + ) # draft, confirmed, in_progress, completed, cancelled + start_date: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + end_date: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + budget: Mapped[float | None] = mapped_column(Float, nullable=True) + total_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + custom_fields: Mapped[str | None] = mapped_column( + Text, nullable=True + ) # JSON string for flexible custom fields + custom_field_defs: Mapped[str | None] = mapped_column( + Text, nullable=True + ) # JSON string for custom field definitions + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="projects") + sub_projects: Mapped[list["SubProject"]] = relationship( + "SubProject", back_populates="project", cascade="all, delete-orphan" + ) + function_groups: Mapped[list["ProjectFunctionGroup"]] = relationship( + "ProjectFunctionGroup", back_populates="project", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"<Project {self.name} ({self.status})>" + + +class SubProject(Base): + """Represents a sub-project with self-referential parent hierarchy.""" + + __tablename__ = "sub_projects" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + project_id: Mapped[str] = mapped_column( + String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + parent_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("sub_projects.id", ondelete="CASCADE"), nullable=True + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + project: Mapped["Project"] = relationship("Project", back_populates="sub_projects") + parent: Mapped[Optional["SubProject"]] = relationship( + "SubProject", remote_side="SubProject.id", back_populates="children" + ) + children: Mapped[list["SubProject"]] = relationship( + "SubProject", back_populates="parent", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"<SubProject {self.name}>" + + +class ProjectFunctionGroup(Base): + """Groups project functions under a project.""" + + __tablename__ = "project_function_groups" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + project_id: Mapped[str] = mapped_column( + String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + project: Mapped["Project"] = relationship( + "Project", back_populates="function_groups" + ) + functions: Mapped[list["ProjectFunction"]] = relationship( + "ProjectFunction", back_populates="function_group", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"<ProjectFunctionGroup {self.name}>" + + +class ProjectFunction(Base): + """Individual function/line-item within a function group.""" + + __tablename__ = "project_functions" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + function_group_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("project_function_groups.id", ondelete="CASCADE"), + nullable=False, + ) + quantity: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + daily_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + total_costs: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + function_group: Mapped["ProjectFunctionGroup"] = relationship( + "ProjectFunctionGroup", back_populates="functions" + ) + + def __repr__(self) -> str: + return f"<ProjectFunction {self.name} x{self.quantity}>" diff --git a/backend/app/models/role.py b/backend/app/models/role.py new file mode 100644 index 0000000..0b72e5d --- /dev/null +++ b/backend/app/models/role.py @@ -0,0 +1,28 @@ +"""Role model for RBAC.""" + +import uuid + +from sqlalchemy import String, ForeignKey, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Role(Base): + """Represents a role with permissions within an account.""" + + __tablename__ = "roles" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + permissions: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="roles") + users: Mapped[list["User"]] = relationship("User", back_populates="role") diff --git a/backend/app/models/stock_location.py b/backend/app/models/stock_location.py new file mode 100644 index 0000000..04cc6f5 --- /dev/null +++ b/backend/app/models/stock_location.py @@ -0,0 +1,47 @@ +"""Stock location model for equipment storage locations.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class StockLocation(Base): + """Represents a physical storage location for equipment.""" + + __tablename__ = "stock_locations" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + address: Mapped[str | None] = mapped_column(String(500), nullable=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="stock_locations") + equipment_items: Mapped[list["Equipment"]] = relationship( + "Equipment", back_populates="location" + ) + equipment_groups: Mapped[list["EquipmentGroup"]] = relationship( + "EquipmentGroup", back_populates="default_location" + ) + + def __repr__(self) -> str: + return f"<StockLocation {self.name}>" diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 0000000..e1d9587 --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,34 @@ +"""Tag model for categorizing entities.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base +from app.models.contact import contact_tags + + +class Tag(Base): + """Represents a tag within a tenant account for categorizing entities.""" + + __tablename__ = "tags" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str | None] = mapped_column(String(7), nullable=True) # hex color + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="tags") + contacts: Mapped[list["Contact"]] = relationship( + "Contact", secondary=contact_tags, back_populates="tags" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ece1427 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,39 @@ +"""User model.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, Boolean, DateTime, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + """Represents a user within a tenant account.""" + + __tablename__ = "users" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("roles.id", ondelete="SET NULL"), nullable=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="users") + role: Mapped["Role | None"] = relationship("Role", back_populates="users") diff --git a/backend/app/models/vehicle.py b/backend/app/models/vehicle.py new file mode 100644 index 0000000..a65050c --- /dev/null +++ b/backend/app/models/vehicle.py @@ -0,0 +1,95 @@ +"""Vehicle and VehicleAssignment models for fleet management.""" + +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func, Float, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Vehicle(Base): + """Represents a vehicle within a tenant account.""" + + __tablename__ = "vehicles" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + account_id: Mapped[str] = mapped_column( + String(36), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + license_plate: Mapped[str | None] = mapped_column(String(50), nullable=True, unique=True) + brand: Mapped[str | None] = mapped_column(String(100), nullable=True) + model: Mapped[str | None] = mapped_column(String(100), nullable=True) + year: Mapped[int | None] = mapped_column(nullable=True) + color: Mapped[str | None] = mapped_column(String(50), nullable=True) + vehicle_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + payload_capacity_kg: Mapped[float | None] = mapped_column(Float, nullable=True) + load_volume_m3: Mapped[float | None] = mapped_column(Float, nullable=True) + fuel_type: Mapped[str | None] = mapped_column(String(30), nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + account: Mapped["Account"] = relationship("Account", back_populates="vehicles") + assignments: Mapped[list["VehicleAssignment"]] = relationship( + "VehicleAssignment", back_populates="vehicle", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"<Vehicle {self.name} ({self.license_plate or 'no plate'})>" + + +class VehicleAssignment(Base): + """Tracks vehicle assignment to projects/events.""" + + __tablename__ = "vehicle_assignments" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + vehicle_id: Mapped[str] = mapped_column( + String(36), ForeignKey("vehicles.id", ondelete="CASCADE"), nullable=False + ) + project_id: Mapped[str | None] = mapped_column( + String(36), nullable=True + ) + start_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + end_date: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="assigned" + ) # assigned, in_use, returned + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + # Relationships + vehicle: Mapped["Vehicle"] = relationship("Vehicle", back_populates="assignments") + + def __repr__(self) -> str: + return f"<VehicleAssignment {self.vehicle_id}: {self.start_date}-{self.end_date}>" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..3343325 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,52 @@ +"""Pydantic schemas for authentication endpoints.""" + +from pydantic import BaseModel, EmailStr, Field + + +class RegisterRequest(BaseModel): + """Request body for self-service registration.""" + account_name: str = Field(..., min_length=2, max_length=255, description="Company/account name") + full_name: str = Field(..., min_length=2, max_length=255, description="Admin user's full name") + email: EmailStr = Field(..., description="Admin user's email address") + password: str = Field(..., min_length=8, max_length=128, description="Password (min 8 characters)") + + +class LoginRequest(BaseModel): + """Request body for login.""" + email: EmailStr + password: str + + +class RefreshRequest(BaseModel): + """Request body for token refresh.""" + refresh_token: str + + +class UserInfo(BaseModel): + """Basic user info returned with tokens (no password hash).""" + id: str + email: str + full_name: str + account_id: str + role_id: str | None = None + role_name: str | None = None + permissions: list[str] = [] + + +class TokenResponse(BaseModel): + """Response with access and refresh tokens, plus user info.""" + access_token: str + refresh_token: str + token_type: str = "bearer" + user: UserInfo + + +class UserResponse(BaseModel): + """Public user representation (no password hash).""" + id: str + email: str + full_name: str + is_active: bool + role_id: str | None = None + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/contact.py b/backend/app/schemas/contact.py new file mode 100644 index 0000000..c6c5652 --- /dev/null +++ b/backend/app/schemas/contact.py @@ -0,0 +1,101 @@ +"""Pydantic schemas for contacts and tags.""" + +from pydantic import BaseModel, Field, EmailStr + + +class TagResponse(BaseModel): + """Public tag representation.""" + id: str + name: str + color: str | None = None + + model_config = {"from_attributes": True} + + +class ContactCreateRequest(BaseModel): + """Request body for creating a new contact.""" + type: str = Field(..., pattern="^(company|person)$") + company_name: str | None = Field(None, max_length=255) + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: str | None = Field(None, max_length=255) + phone: str | None = Field(None, max_length=50) + mobile: str | None = Field(None, max_length=50) + website: str | None = Field(None, max_length=500) + billing_street: str | None = Field(None, max_length=255) + billing_number: str | None = Field(None, max_length=20) + billing_postalcode: str | None = Field(None, max_length=20) + billing_city: str | None = Field(None, max_length=100) + billing_country: str | None = Field(None, max_length=100) + shipping_street: str | None = Field(None, max_length=255) + shipping_number: str | None = Field(None, max_length=20) + shipping_postalcode: str | None = Field(None, max_length=20) + shipping_city: str | None = Field(None, max_length=100) + shipping_country: str | None = Field(None, max_length=100) + tax_number: str | None = Field(None, max_length=50) + note: str | None = None + tag_ids: list[str] = [] + + +class ContactUpdateRequest(BaseModel): + """Request body for updating an existing contact.""" + type: str | None = Field(None, pattern="^(company|person)$") + company_name: str | None = Field(None, max_length=255) + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: str | None = Field(None, max_length=255) + phone: str | None = Field(None, max_length=50) + mobile: str | None = Field(None, max_length=50) + website: str | None = Field(None, max_length=500) + billing_street: str | None = Field(None, max_length=255) + billing_number: str | None = Field(None, max_length=20) + billing_postalcode: str | None = Field(None, max_length=20) + billing_city: str | None = Field(None, max_length=100) + billing_country: str | None = Field(None, max_length=100) + shipping_street: str | None = Field(None, max_length=255) + shipping_number: str | None = Field(None, max_length=20) + shipping_postalcode: str | None = Field(None, max_length=20) + shipping_city: str | None = Field(None, max_length=100) + shipping_country: str | None = Field(None, max_length=100) + tax_number: str | None = Field(None, max_length=50) + note: str | None = None + tag_ids: list[str] | None = None + + +class ContactResponse(BaseModel): + """Public contact representation with tags.""" + id: str + account_id: str + type: str + company_name: str | None = None + first_name: str | None = None + last_name: str | None = None + email: str | None = None + phone: str | None = None + mobile: str | None = None + website: str | None = None + billing_street: str | None = None + billing_number: str | None = None + billing_postalcode: str | None = None + billing_city: str | None = None + billing_country: str | None = None + shipping_street: str | None = None + shipping_number: str | None = None + shipping_postalcode: str | None = None + shipping_city: str | None = None + shipping_country: str | None = None + tax_number: str | None = None + note: str | None = None + created_at: str + updated_at: str + tags: list[TagResponse] = [] + + model_config = {"from_attributes": True} + + +class ContactListResponse(BaseModel): + """Paginated list of contacts.""" + items: list[ContactResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/crew.py b/backend/app/schemas/crew.py new file mode 100644 index 0000000..079342c --- /dev/null +++ b/backend/app/schemas/crew.py @@ -0,0 +1,94 @@ +"""Pydantic schemas for Crew and CrewAvailability.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class CrewAvailabilityResponse(BaseModel): + """Public crew availability representation.""" + id: str + crew_id: str + start_date: str + end_date: str + status: str + notes: str | None = None + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class CrewCreateRequest(BaseModel): + """Request body for creating a new crew member.""" + first_name: str = Field(..., max_length=100) + last_name: str = Field(..., max_length=100) + email: str | None = Field(None, max_length=255) + phone: str | None = Field(None, max_length=50) + role_title: str | None = Field(None, max_length=100) + hourly_rate: float | None = None + is_active: bool = True + notes: str | None = None + + +class CrewUpdateRequest(BaseModel): + """Request body for updating a crew member.""" + first_name: str | None = Field(None, max_length=100) + last_name: str | None = Field(None, max_length=100) + email: str | None = Field(None, max_length=255) + phone: str | None = Field(None, max_length=50) + role_title: str | None = Field(None, max_length=100) + hourly_rate: float | None = None + is_active: bool | None = None + notes: str | None = None + + +class CrewResponse(BaseModel): + """Public crew member representation.""" + id: str + account_id: str + first_name: str + last_name: str + email: str | None = None + phone: str | None = None + role_title: str | None = None + hourly_rate: float | None = None + is_active: bool + notes: str | None = None + created_at: str + updated_at: str + availabilities: list[CrewAvailabilityResponse] = [] + + model_config = {"from_attributes": True} + + +class CrewListResponse(BaseModel): + """Paginated list of crew members.""" + items: list[CrewResponse] + total: int + page: int + size: int + + +class CrewAvailabilityCreateRequest(BaseModel): + """Request body for creating crew availability.""" + crew_id: str + start_date: str = Field(..., description="ISO datetime with timezone") + end_date: str = Field(..., description="ISO datetime with timezone") + status: str = Field("available", max_length=20) + notes: str | None = None + + +class CrewAvailabilityUpdateRequest(BaseModel): + """Request body for updating crew availability.""" + start_date: str | None = None + end_date: str | None = None + status: str | None = Field(None, max_length=20) + notes: str | None = None + + +class CrewAvailabilityListResponse(BaseModel): + """Paginated list of crew availabilities.""" + items: list[CrewAvailabilityResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/equipment.py b/backend/app/schemas/equipment.py new file mode 100644 index 0000000..bff0de7 --- /dev/null +++ b/backend/app/schemas/equipment.py @@ -0,0 +1,95 @@ +"""Pydantic schemas for Equipment (inventory catalog).""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class EquipmentCreateRequest(BaseModel): + """Request body for creating new equipment.""" + name: str = Field(..., max_length=255) + category: str | None = Field(None, max_length=100) + brand: str | None = Field(None, max_length=255) + serial_number: str | None = Field(None, max_length=100) + barcode: str | None = Field(None, max_length=100) + qr_code: str | None = Field(None, max_length=500) + status: str = Field("available", max_length=20) + purchase_price: float | None = None + current_value: float | None = None + weight_kg: float | None = None + dimensions: str | None = Field(None, max_length=255) + power_watt: float | None = None + notes: str | None = None + custom_fields: str | None = None + location_id: str | None = None + supplier_id: str | None = None + + +class EquipmentUpdateRequest(BaseModel): + """Request body for updating existing equipment.""" + name: str | None = Field(None, max_length=255) + category: str | None = Field(None, max_length=100) + brand: str | None = Field(None, max_length=255) + serial_number: str | None = Field(None, max_length=100) + barcode: str | None = Field(None, max_length=100) + qr_code: str | None = Field(None, max_length=500) + status: str | None = Field(None, max_length=20) + purchase_price: float | None = None + current_value: float | None = None + weight_kg: float | None = None + dimensions: str | None = Field(None, max_length=255) + power_watt: float | None = None + notes: str | None = None + custom_fields: str | None = None + location_id: str | None = None + supplier_id: str | None = None + + +class LocationRef(BaseModel): + """Minimal stock location reference.""" + id: str + name: str + + model_config = {"from_attributes": True} + + +class SupplierRef(BaseModel): + """Minimal supplier (contact) reference.""" + id: str + name: str | None = None + company_name: str | None = None + + model_config = {"from_attributes": True} + + +class EquipmentResponse(BaseModel): + """Public equipment representation.""" + id: str + account_id: str + name: str + category: str | None = None + brand: str | None = None + serial_number: str | None = None + barcode: str | None = None + qr_code: str | None = None + status: str + purchase_price: float | None = None + current_value: float | None = None + weight_kg: float | None = None + dimensions: str | None = None + power_watt: float | None = None + notes: str | None = None + custom_fields: str | None = None + location: LocationRef | None = None + supplier: SupplierRef | None = None + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class EquipmentListResponse(BaseModel): + """Paginated list of equipment.""" + items: list[EquipmentResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/equipment_group.py b/backend/app/schemas/equipment_group.py new file mode 100644 index 0000000..c439820 --- /dev/null +++ b/backend/app/schemas/equipment_group.py @@ -0,0 +1,58 @@ +"""Pydantic schemas for EquipmentGroup (Bundles).""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class EquipmentGroupItem(BaseModel): + """An item inside a bundle with quantity.""" + equipment_id: str + name: str | None = None + quantity: float = 1.0 + + model_config = {"from_attributes": True} + + +class EquipmentGroupCreateRequest(BaseModel): + """Request body for creating a new equipment group/bundle.""" + name: str = Field(..., max_length=255) + description: str | None = None + daily_rate: float | None = None + default_location_id: str | None = None + items: list[EquipmentGroupItem] = [] + + +class EquipmentGroupUpdateRequest(BaseModel): + """Request body for updating an equipment group.""" + name: str | None = Field(None, max_length=255) + description: str | None = None + daily_rate: float | None = None + default_location_id: str | None = None + items: list[EquipmentGroupItem] | None = None + + +class EquipmentGroupResponse(BaseModel): + """Public equipment group representation.""" + id: str + account_id: str + name: str + description: str | None = None + daily_rate: float | None = None + default_location: LocationRef | None = None + items: list[EquipmentGroupItem] = [] + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class EquipmentGroupListResponse(BaseModel): + """Paginated list of equipment groups.""" + items: list[EquipmentGroupResponse] + total: int + page: int + size: int + + +# Need LocationRef for EquipmentGroupResponse +from app.schemas.equipment import LocationRef # noqa: E402, F811 diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..1e97309 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,170 @@ +"""Pydantic schemas for Project, SubProject, ProjectFunctionGroup, ProjectFunction.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +# ===== Project Schemas ===== + +class ProjectCreateRequest(BaseModel): + """Request body for creating a new project.""" + name: str = Field(..., max_length=255) + description: str | None = None + status: str = Field("draft", max_length=20) + start_date: datetime | None = None + end_date: datetime | None = None + budget: float | None = None + total_costs: float = 0.0 + notes: str | None = None + custom_fields: str | None = None + custom_field_defs: str | None = None + + +class ProjectUpdateRequest(BaseModel): + """Request body for updating an existing project.""" + name: str | None = Field(None, max_length=255) + description: str | None = None + status: str | None = Field(None, max_length=20) + start_date: datetime | None = None + end_date: datetime | None = None + budget: float | None = None + total_costs: float | None = None + notes: str | None = None + custom_fields: str | None = None + custom_field_defs: str | None = None + + +class ProjectResponse(BaseModel): + """Public project representation.""" + id: str + account_id: str + name: str + description: str | None = None + status: str + start_date: datetime | None = None + end_date: datetime | None = None + budget: float | None = None + total_costs: float = 0.0 + notes: str | None = None + custom_fields: str | None = None + custom_field_defs: str | None = None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class ProjectListResponse(BaseModel): + """Paginated list of projects.""" + items: list[ProjectResponse] + total: int + page: int + size: int + + +# ===== SubProject Schemas ===== + +class SubProjectCreateRequest(BaseModel): + """Request body for creating a subproject.""" + name: str = Field(..., max_length=255) + parent_id: str | None = None + sort_order: int = 0 + + +class SubProjectUpdateRequest(BaseModel): + """Request body for updating a subproject.""" + name: str | None = Field(None, max_length=255) + parent_id: str | None = None + sort_order: int | None = None + + +class SubProjectResponse(BaseModel): + """Public subproject representation.""" + id: str + name: str + project_id: str + parent_id: str | None = None + sort_order: int = 0 + + model_config = {"from_attributes": True} + + +class SubProjectListResponse(BaseModel): + """Paginated list of subprojects.""" + items: list[SubProjectResponse] + total: int + page: int + size: int + + +# ===== ProjectFunctionGroup Schemas ===== + +class ProjectFunctionGroupCreateRequest(BaseModel): + """Request body for creating a function group.""" + name: str = Field(..., max_length=255) + sort_order: int = 0 + + +class ProjectFunctionGroupUpdateRequest(BaseModel): + """Request body for updating a function group.""" + name: str | None = Field(None, max_length=255) + sort_order: int | None = None + + +class ProjectFunctionGroupResponse(BaseModel): + """Public function group representation.""" + id: str + name: str + project_id: str + sort_order: int = 0 + + model_config = {"from_attributes": True} + + +class ProjectFunctionGroupListResponse(BaseModel): + """Paginated list of function groups.""" + items: list[ProjectFunctionGroupResponse] + total: int + page: int + size: int + + +# ===== ProjectFunction Schemas ===== + +class ProjectFunctionCreateRequest(BaseModel): + """Request body for creating a project function.""" + name: str = Field(..., max_length=255) + quantity: float = 1.0 + daily_costs: float = 0.0 + total_costs: float = 0.0 + sort_order: int = 0 + + +class ProjectFunctionUpdateRequest(BaseModel): + """Request body for updating a project function.""" + name: str | None = Field(None, max_length=255) + quantity: float | None = None + daily_costs: float | None = None + total_costs: float | None = None + sort_order: int | None = None + + +class ProjectFunctionResponse(BaseModel): + """Public project function representation.""" + id: str + name: str + function_group_id: str + quantity: float = 1.0 + daily_costs: float = 0.0 + total_costs: float = 0.0 + sort_order: int = 0 + + model_config = {"from_attributes": True} + + +class ProjectFunctionListResponse(BaseModel): + """Paginated list of project functions.""" + items: list[ProjectFunctionResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/role.py b/backend/app/schemas/role.py new file mode 100644 index 0000000..2e9a4dd --- /dev/null +++ b/backend/app/schemas/role.py @@ -0,0 +1,28 @@ +"""Pydantic schemas for role management.""" + +from pydantic import BaseModel, Field + + +class RoleCreateRequest(BaseModel): + """Request body for creating a new role.""" + name: str = Field(..., min_length=2, max_length=100) + description: str | None = None + permissions: list[str] = Field(default_factory=list) + + +class RoleUpdateRequest(BaseModel): + """Request body for updating an existing role.""" + name: str | None = Field(None, min_length=2, max_length=100) + description: str | None = None + permissions: list[str] | None = None + + +class RoleResponse(BaseModel): + """Public role representation.""" + id: str + account_id: str + name: str + description: str | None = None + permissions: list[str] + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/stock_location.py b/backend/app/schemas/stock_location.py new file mode 100644 index 0000000..a0b46c7 --- /dev/null +++ b/backend/app/schemas/stock_location.py @@ -0,0 +1,39 @@ +"""Pydantic schemas for StockLocation.""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class StockLocationCreateRequest(BaseModel): + """Request body for creating a new stock location.""" + name: str = Field(..., max_length=255) + address: str | None = Field(None, max_length=500) + is_default: bool = False + + +class StockLocationUpdateRequest(BaseModel): + """Request body for updating a stock location.""" + name: str | None = Field(None, max_length=255) + address: str | None = Field(None, max_length=500) + is_default: bool | None = None + + +class StockLocationResponse(BaseModel): + """Public stock location representation.""" + id: str + account_id: str + name: str + address: str | None = None + is_default: bool + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class StockLocationListResponse(BaseModel): + """Paginated list of stock locations.""" + items: list[StockLocationResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..2856415 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,40 @@ +"""Pydantic schemas for user management.""" + +from pydantic import BaseModel, EmailStr, Field + + +class UserCreateRequest(BaseModel): + """Request body for creating a new user (invite).""" + email: EmailStr + full_name: str = Field(..., min_length=2, max_length=255) + password: str = Field(..., min_length=8, max_length=128) + role_id: str | None = None + + +class UserUpdateRequest(BaseModel): + """Request body for updating an existing user.""" + full_name: str | None = Field(None, min_length=2, max_length=255) + role_id: str | None = None + is_active: bool | None = None + + +class UserResponse(BaseModel): + """Public user representation (no password hash).""" + id: str + account_id: str + email: str + full_name: str + is_active: bool + role_id: str | None = None + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class UserListResponse(BaseModel): + """Paginated list of users.""" + items: list[UserResponse] + total: int + page: int + size: int diff --git a/backend/app/schemas/vehicle.py b/backend/app/schemas/vehicle.py new file mode 100644 index 0000000..01cd132 --- /dev/null +++ b/backend/app/schemas/vehicle.py @@ -0,0 +1,107 @@ +"""Pydantic schemas for Vehicles and VehicleAssignments.""" + +from pydantic import BaseModel, Field + + +class VehicleAssignmentResponse(BaseModel): + """Public vehicle assignment representation.""" + id: str + vehicle_id: str + project_id: str | None = None + start_date: str + end_date: str + status: str + notes: str | None = None + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class VehicleCreateRequest(BaseModel): + """Request body for creating a new vehicle.""" + name: str = Field(..., max_length=255) + license_plate: str | None = Field(None, max_length=50) + brand: str | None = Field(None, max_length=100) + model: str | None = Field(None, max_length=100) + year: int | None = None + color: str | None = Field(None, max_length=50) + vehicle_type: str | None = Field(None, max_length=50) + payload_capacity_kg: float | None = None + load_volume_m3: float | None = None + fuel_type: str | None = Field(None, max_length=30) + is_active: bool = True + notes: str | None = None + + +class VehicleUpdateRequest(BaseModel): + """Request body for updating a vehicle.""" + name: str | None = Field(None, max_length=255) + license_plate: str | None = Field(None, max_length=50) + brand: str | None = Field(None, max_length=100) + model: str | None = Field(None, max_length=100) + year: int | None = None + color: str | None = Field(None, max_length=50) + vehicle_type: str | None = Field(None, max_length=50) + payload_capacity_kg: float | None = None + load_volume_m3: float | None = None + fuel_type: str | None = Field(None, max_length=30) + is_active: bool | None = None + notes: str | None = None + + +class VehicleResponse(BaseModel): + """Public vehicle representation.""" + id: str + account_id: str + name: str + license_plate: str | None = None + brand: str | None = None + model: str | None = None + year: int | None = None + color: str | None = None + vehicle_type: str | None = None + payload_capacity_kg: float | None = None + load_volume_m3: float | None = None + fuel_type: str | None = None + is_active: bool + notes: str | None = None + assignments: list[VehicleAssignmentResponse] = [] + created_at: str + updated_at: str + + model_config = {"from_attributes": True} + + +class VehicleListResponse(BaseModel): + """Paginated list of vehicles.""" + items: list[VehicleResponse] + total: int + page: int + size: int + + +class VehicleAssignmentCreateRequest(BaseModel): + """Request body for creating a vehicle assignment.""" + vehicle_id: str + project_id: str | None = None + start_date: str = Field(..., description="ISO datetime") + end_date: str = Field(..., description="ISO datetime") + status: str = Field("assigned", max_length=20) + notes: str | None = None + + +class VehicleAssignmentUpdateRequest(BaseModel): + """Request body for updating a vehicle assignment.""" + start_date: str | None = None + end_date: str | None = None + status: str | None = Field(None, max_length=20) + notes: str | None = None + + +class VehicleAssignmentListResponse(BaseModel): + """Paginated list of vehicle assignments.""" + items: list[VehicleAssignmentResponse] + total: int + page: int + size: int diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..87ff49f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.36 +aiosqlite==0.20.0 +alembic==1.14.1 +greenlet==3.5.1 +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +pydantic-settings==2.7.1 +redis==5.2.1 +minio==7.2.14 +python-multipart==0.0.20 +email-validator==2.2.0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d55add8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,65 @@ +version: "3.9" + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: rentman-backend + ports: + - "8000:8000" + volumes: + - ./backend:/app + - backend_data:/app/data + env_file: + - .env + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/rentman.db + - REDIS_URL=redis://redis:6379/0 + - MINIO_ENDPOINT=minio:9000 + depends_on: + - redis + - minio + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: rentman-frontend + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_BASE_URL=http://localhost:8000/api/v1 + depends_on: + - backend + command: npm run dev -- --host 0.0.0.0 + + redis: + image: redis:7-alpine + container_name: rentman-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + + minio: + image: minio/minio:latest + container_name: rentman-minio + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + env_file: + - .env + command: server /data --console-address ":9001" + +volumes: + backend_data: + redis_data: + minio_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..72b665a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine + +WORKDIR /app + +# Copy package files and install dependencies +COPY package.json package-lock.json* ./ +RUN npm install + +# Copy application code +COPY . . + +# Expose Vite dev server port +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..90ba4e0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Rentman Clone + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2f9ab8f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4616 @@ +{ + "name": "rentman-clone-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rentman-clone-frontend", + "version": "0.1.0", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.2.0", + "@tanstack/react-query": "^5.75.5", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.515.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.0", + "tailwind-merge": "^3.2.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@tailwindcss/vite": "^4.1.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.0", + "globals": "^16.0.0", + "tailwindcss": "^4.1.0", + "typescript": "~5.8.0", + "typescript-eslint": "^8.30.0", + "vite": "^6.3.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", + "integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz", + "integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true + }, + "node_modules/enhanced-resolve": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.1.tgz", + "integrity": "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "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" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.515.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.515.0.tgz", + "integrity": "sha512-Sy7bY0MeicRm2pzrnoHm2h6C1iVoeHyBU2fjdQDsXGP51fhkhau1/ZV/dzrcxEmAKsxYb6bGaIsMnGHuQ5s0dw==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz", + "integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz", + "integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==", + "dependencies": { + "react-router": "7.16.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..853f481 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "rentman-clone-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.0", + "zustand": "^5.0.3", + "@tanstack/react-query": "^5.75.5", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-dialog": "^1.1.0", + "@radix-ui/react-dropdown-menu": "^2.1.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-separator": "^1.1.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "tailwind-merge": "^3.2.0", + "lucide-react": "^0.515.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.8.0", + "vite": "^6.3.0", + "tailwindcss": "^4.1.0", + "@tailwindcss/vite": "^4.1.0", + "@eslint/js": "^9.25.0", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.0", + "globals": "^16.0.0", + "typescript-eslint": "^8.30.0" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..57c3ab2 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,143 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import AppLayout from './components/AppLayout.tsx'; +import ProtectedRoute from './components/ProtectedRoute.tsx'; +import Home from './pages/Home.tsx'; +import Login from './pages/Login.tsx'; +import Register from './pages/Register.tsx'; +import Dashboard from './pages/Dashboard.tsx'; +import Users from './pages/Users.tsx'; +import Roles from './pages/Roles.tsx'; +import Contacts from './pages/Contacts.tsx'; +import ContactsNew from './pages/ContactsNew.tsx'; +import ContactsEdit from './pages/ContactsEdit.tsx'; +import ContactDetail from './pages/ContactDetail.tsx'; +import Equipment from './pages/Equipment.tsx'; +import EquipmentNew from './pages/EquipmentNew.tsx'; +import EquipmentEdit from './pages/EquipmentEdit.tsx'; +import EquipmentDetail from './pages/EquipmentDetail.tsx'; +import CrewPage from './pages/Crew.tsx'; +import CrewNew from './pages/CrewNew.tsx'; +import CrewEdit from './pages/CrewEdit.tsx'; +import CrewDetail from './pages/CrewDetail.tsx'; +import Vehicles from './pages/Vehicles.tsx'; +import VehicleNew from './pages/VehicleNew.tsx'; +import VehicleEdit from './pages/VehicleEdit.tsx'; +import VehicleDetail from './pages/VehicleDetail.tsx'; +import Projects from './pages/Projects.tsx'; +import ProjectNew from './pages/ProjectNew.tsx'; +import ProjectEdit from './pages/ProjectEdit.tsx'; +import ProjectDetail from './pages/ProjectDetail.tsx'; + +export default function App() { + return ( + + {/* Public routes */} + } /> + } /> + } /> + + {/* Protected routes (require authentication) */} + }> + }> + } /> + + + + {/* Contacts routes (require contacts:read permission) */} + }> + }> + } /> + } /> + + + + {/* Contacts write routes (require contacts:write permission) */} + }> + }> + } /> + } /> + + + + {/* Equipment routes (require equipment:read permission) */} + }> + }> + } /> + } /> + + + + {/* Equipment write routes (require equipment:write permission) */} + }> + }> + } /> + } /> + + + + {/* Crew routes (require crew:read permission) */} + }> + }> + } /> + } /> + + + + {/* Crew write routes (require crew:write permission) */} + }> + }> + } /> + } /> + + + + {/* Vehicles routes (require vehicles:read permission) */} + }> + }> + } /> + } /> + + + + {/* Vehicles write routes (require vehicles:write permission) */} + }> + }> + } /> + } /> + + + + {/* Projects routes (require projects:read permission) */} + }> + }> + } /> + } /> + + + + {/* Projects write routes (require projects:write permission) */} + }> + }> + } /> + } /> + + + + {/* Admin routes (require users:read permission) */} + }> + }> + } /> + + + + }> + }> + } /> + + + + {/* Catch-all: redirect to dashboard if authenticated, else home */} + } /> + + ); +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx new file mode 100644 index 0000000..08e5715 --- /dev/null +++ b/frontend/src/components/AppLayout.tsx @@ -0,0 +1,136 @@ +import { Outlet, Link, useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore.ts'; +import { + LayoutDashboard, + Package, + Users, + Truck, + FolderKanban, + LogOut, + Menu, + Shield, + AddressBook, +} from 'lucide-react'; +import { useState } from 'react'; + +interface SidebarLink { + to: string; + label: string; + icon: typeof LayoutDashboard; + permission?: string; +} + +export default function AppLayout() { + const { user, logout } = useAuthStore(); + const navigate = useNavigate(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const sidebarLinks: SidebarLink[] = [ + { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { to: '/contacts', label: 'Contacts', icon: AddressBook }, + { to: '/projects', label: 'Projects', icon: FolderKanban }, + { to: '/equipment', label: 'Equipment', icon: Package }, + { to: '/crew', label: 'Crew', icon: Users }, + { to: '/vehicles', label: 'Vehicles', icon: Truck }, + ]; + + // Admin links – only show if user has the required permission + if (user?.permissions?.includes('users:read')) { + sidebarLinks.push({ to: '/users', label: 'Manage Users', icon: Shield, permission: 'users:read' }); + } + if (user?.permissions?.includes('roles:read')) { + sidebarLinks.push({ to: '/roles', label: 'Manage Roles', icon: Shield, permission: 'roles:read' }); + } + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + return ( +
+ {/* Sidebar */} + + + {/* Overlay for mobile */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Main content */} +
+ {/* Header */} +
+ +

+ Rentman Clone +

+
+ + {/* Page content */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ContactForm.tsx b/frontend/src/components/ContactForm.tsx new file mode 100644 index 0000000..884fd49 --- /dev/null +++ b/frontend/src/components/ContactForm.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from 'react'; +import api from '../services/api.ts'; +import { X } from 'lucide-react'; + +interface TagItem { + id: string; + name: string; + color: string | null; +} + +export interface ContactFormData { + type: 'company' | 'person'; + company_name: string; + first_name: string; + last_name: string; + email: string; + phone: string; + mobile: string; + website: string; + billing_street: string; + billing_number: string; + billing_postalcode: string; + billing_city: string; + billing_country: string; + shipping_street: string; + shipping_number: string; + shipping_postalcode: string; + shipping_city: string; + shipping_country: string; + tax_number: string; + note: string; + tag_ids: string[]; +} + +export const emptyFormData: ContactFormData = { + type: 'company', + company_name: '', + first_name: '', + last_name: '', + email: '', + phone: '', + mobile: '', + website: '', + billing_street: '', + billing_number: '', + billing_postalcode: '', + billing_city: '', + billing_country: '', + shipping_street: '', + shipping_number: '', + shipping_postalcode: '', + shipping_city: '', + shipping_country: '', + tax_number: '', + note: '', + tag_ids: [], +}; + +interface ContactFormProps { + initialData?: ContactFormData; + onSubmit: (data: ContactFormData) => Promise; + submitLabel?: string; +} + +export default function ContactForm({ initialData, onSubmit, submitLabel = 'Save' }: ContactFormProps) { + const [form, setForm] = useState(initialData || emptyFormData); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [newTagName, setNewTagName] = useState(''); + + useEffect(() => { + loadTags(); + }, []); + + const loadTags = async () => { + try { + const response = await api.get('/tags'); + setTags(response.data); + } catch (err: any) { + console.error('Failed to load tags', err); + } + }; + + const handleCreateTag = async () => { + if (!newTagName.trim()) return; + try { + const response = await api.post('/tags', { name: newTagName.trim() }); + setTags([...tags, response.data]); + setForm({ ...form, tag_ids: [...form.tag_ids, response.data.id] }); + setNewTagName(''); + } catch (err: any) { + alert(err.response?.data?.detail || 'Failed to create tag'); + } + }; + + const toggleTag = (tagId: string) => { + setForm(prev => ({ + ...prev, + tag_ids: prev.tag_ids.includes(tagId) + ? prev.tag_ids.filter(id => id !== tagId) + : [...prev.tag_ids, tagId], + })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await onSubmit(form); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to save contact'); + } finally { + setLoading(false); + } + }; + + const updateField = (field: keyof ContactFormData, value: any) => { + setForm(prev => ({ ...prev, [field]: value })); + }; + + const setType = (type: 'company' | 'person') => { + setForm(prev => ({ ...prev, type })); + }; + + const inputClass = "w-full px-3 py-2 border border-border rounded-lg focus:ring-2 focus:ring-primary outline-none text-sm"; + const labelClass = "block text-sm font-medium text-text-primary mb-1"; + const sectionClass = "mb-6"; + + return ( +
+ {error && ( +
{error}
+ )} + + {/* Type Tabs */} +
+
+ + +
+
+ + {/* Basic Info */} +
+

+ {form.type === 'company' ? 'Company Information' : 'Personal Information'} +

+
+ {form.type === 'company' && ( +
+ + updateField('company_name', e.target.value)} + required className={inputClass} /> +
+ )} + {form.type === 'person' && ( + <> +
+ + updateField('first_name', e.target.value)} + required className={inputClass} /> +
+
+ + updateField('last_name', e.target.value)} + required className={inputClass} /> +
+ + )} +
+ + updateField('email', e.target.value)} + className={inputClass} /> +
+
+ + updateField('phone', e.target.value)} + className={inputClass} /> +
+
+ + updateField('mobile', e.target.value)} + className={inputClass} /> +
+
+ + updateField('website', e.target.value)} + className={inputClass} placeholder="https://" /> +
+
+ + updateField('tax_number', e.target.value)} + className={inputClass} /> +
+
+
+ + {/* Tags */} +
+

Tags

+
+ {tags.map(tag => ( + + ))} +
+
+ setNewTagName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCreateTag(); } }} + className={`${inputClass} flex-1`} + /> + +
+
+ + {/* Billing Address */} +
+

Billing Address

+
+
+ + updateField('billing_street', e.target.value)} + className={inputClass} /> +
+
+ + updateField('billing_number', e.target.value)} + className={inputClass} /> +
+
+ + updateField('billing_postalcode', e.target.value)} + className={inputClass} /> +
+
+ + updateField('billing_city', e.target.value)} + className={inputClass} /> +
+
+ + updateField('billing_country', e.target.value)} + className={inputClass} /> +
+
+
+ + {/* Shipping Address */} +
+

+ Shipping Address + +

+
+
+ + updateField('shipping_street', e.target.value)} + className={inputClass} /> +
+
+ + updateField('shipping_number', e.target.value)} + className={inputClass} /> +
+
+ + updateField('shipping_postalcode', e.target.value)} + className={inputClass} /> +
+
+ + updateField('shipping_city', e.target.value)} + className={inputClass} /> +
+
+ + updateField('shipping_country', e.target.value)} + className={inputClass} /> +
+
+
+ + {/* Notes */} +
+

Notes

+