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
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
# Project Name
|
||||
|
||||
## Description
|
||||
What this project does.
|
||||
|
||||
## Quick Start
|
||||
How to get started quickly.
|
||||
|
||||
## Documentation
|
||||
Links to key documentation files.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+730
@@ -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.
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# Environment Variables
|
||||
|
||||
## Required
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
|
||||
## Optional
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
|
||||
## .env.example
|
||||
```
|
||||
# Copy to .env and fill in values
|
||||
```
|
||||
@@ -0,0 +1,12 @@
|
||||
# Healthcheck
|
||||
|
||||
## Endpoint
|
||||
|
||||
## Expected Response
|
||||
|
||||
## How to Check
|
||||
```bash
|
||||
curl http://localhost:PORT/health
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -0,0 +1,20 @@
|
||||
# Known Errors
|
||||
|
||||
## Open Errors
|
||||
- (none)
|
||||
|
||||
## Resolved Errors
|
||||
- (none)
|
||||
|
||||
## Won't Fix
|
||||
- (none)
|
||||
|
||||
## Error Log Format
|
||||
```
|
||||
### Error: <title>
|
||||
- Status: open | in_progress | resolved | wont_fix
|
||||
- Discovered: <timestamp>
|
||||
- Severity: critical | high | medium | low
|
||||
- Context: <description>
|
||||
- Resolution: <how it was fixed or workaround>
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
+1092
File diff suppressed because it is too large
Load Diff
+241
@@ -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! 🚀**
|
||||
@@ -0,0 +1,9 @@
|
||||
# Rollback
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
## Procedure
|
||||
|
||||
## Verification
|
||||
|
||||
## Emergency Contacts
|
||||
@@ -0,0 +1,13 @@
|
||||
# Runbook
|
||||
|
||||
## Start
|
||||
|
||||
## Stop
|
||||
|
||||
## Restart
|
||||
|
||||
## Logs
|
||||
|
||||
## Common Issues
|
||||
|
||||
## Monitoring
|
||||
@@ -0,0 +1,15 @@
|
||||
# Runtime Report
|
||||
|
||||
## Date
|
||||
|
||||
## Application Status
|
||||
|
||||
## Logs Summary
|
||||
|
||||
## Healthcheck
|
||||
|
||||
## Browser Smoke Test
|
||||
|
||||
## Errors Found
|
||||
|
||||
## Recommendations
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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**: ...
|
||||
@@ -0,0 +1,13 @@
|
||||
# Test Report
|
||||
|
||||
## Date
|
||||
|
||||
## Test Commands
|
||||
|
||||
## Results
|
||||
|
||||
## Failures
|
||||
|
||||
## Coverage
|
||||
|
||||
## Known Issues
|
||||
+39
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+141
@@ -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
|
||||
@@ -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=
|
||||
+47
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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])
|
||||
@@ -0,0 +1,8 @@
|
||||
"""SQLAlchemy declarative base."""
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all database models."""
|
||||
pass
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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}>"
|
||||
@@ -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})>"
|
||||
@@ -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}>"
|
||||
@@ -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}>"
|
||||
@@ -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")
|
||||
@@ -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}>"
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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}>"
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4616
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Protected routes (require authentication) */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Contacts routes (require contacts:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="contacts:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/contacts" element={<Contacts />} />
|
||||
<Route path="/contacts/:contactId" element={<ContactDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Contacts write routes (require contacts:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="contacts:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/contacts/new" element={<ContactsNew />} />
|
||||
<Route path="/contacts/:contactId/edit" element={<ContactsEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Equipment routes (require equipment:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="equipment:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/equipment" element={<Equipment />} />
|
||||
<Route path="/equipment/:itemId" element={<EquipmentDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Equipment write routes (require equipment:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="equipment:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/equipment/new" element={<EquipmentNew />} />
|
||||
<Route path="/equipment/:itemId/edit" element={<EquipmentEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Crew routes (require crew:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="crew:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/crew" element={<CrewPage />} />
|
||||
<Route path="/crew/:memberId" element={<CrewDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Crew write routes (require crew:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="crew:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/crew/new" element={<CrewNew />} />
|
||||
<Route path="/crew/:memberId/edit" element={<CrewEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Vehicles routes (require vehicles:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="vehicles:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/vehicles" element={<Vehicles />} />
|
||||
<Route path="/vehicles/:vehicleId" element={<VehicleDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Vehicles write routes (require vehicles:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="vehicles:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/vehicles/new" element={<VehicleNew />} />
|
||||
<Route path="/vehicles/:vehicleId/edit" element={<VehicleEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Projects routes (require projects:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="projects:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/projects/:projectId" element={<ProjectDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Projects write routes (require projects:write permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="projects:write" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/projects/new" element={<ProjectNew />} />
|
||||
<Route path="/projects/:projectId/edit" element={<ProjectEdit />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Admin routes (require users:read permission) */}
|
||||
<Route element={<ProtectedRoute requiredPermission="users:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/users" element={<Users />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route element={<ProtectedRoute requiredPermission="roles:read" />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all: redirect to dashboard if authenticated, else home */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
fixed inset-y-0 left-0 z-30 w-64 bg-surface border-r border-border
|
||||
transform transition-transform lg:translate-x-0 lg:static lg:z-auto
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-border">
|
||||
<Link to="/dashboard" className="text-xl font-bold text-primary">
|
||||
Rentman
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto">
|
||||
{sidebarLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
return (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-text-secondary hover:bg-primary/5 hover:text-primary transition-colors"
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span className="text-sm font-medium">{link.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border">
|
||||
{user && (
|
||||
<div className="mb-3 px-3">
|
||||
<p className="text-sm font-medium text-text-primary truncate">
|
||||
{user.full_name}
|
||||
</p>
|
||||
<p className="text-xs text-text-secondary truncate">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.role_name && (
|
||||
<p className="text-xs text-primary mt-0.5">{user.role_name}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 w-full px-3 py-2.5 rounded-lg text-text-secondary hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span className="text-sm font-medium">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Overlay for mobile */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<header className="h-16 bg-surface border-b border-border flex items-center px-6">
|
||||
<button
|
||||
className="lg:hidden mr-4 text-text-secondary hover:text-text-primary"
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
Rentman Clone
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function ContactForm({ initialData, onSubmit, submitLabel = 'Save' }: ContactFormProps) {
|
||||
const [form, setForm] = useState<ContactFormData>(initialData || emptyFormData);
|
||||
const [tags, setTags] = useState<TagItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className={sectionClass}>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('company')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === 'company'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Company
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('person')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === 'person'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface border border-border text-text-secondary hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Person
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">
|
||||
{form.type === 'company' ? 'Company Information' : 'Personal Information'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.type === 'company' && (
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Company Name *</label>
|
||||
<input type="text" value={form.company_name} onChange={e => updateField('company_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'person' && (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>First Name *</label>
|
||||
<input type="text" value={form.first_name} onChange={e => updateField('first_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name *</label>
|
||||
<input type="text" value={form.last_name} onChange={e => updateField('last_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" value={form.email} onChange={e => updateField('email', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input type="text" value={form.phone} onChange={e => updateField('phone', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Mobile</label>
|
||||
<input type="text" value={form.mobile} onChange={e => updateField('mobile', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Website</label>
|
||||
<input type="url" value={form.website} onChange={e => updateField('website', e.target.value)}
|
||||
className={inputClass} placeholder="https://" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tax Number</label>
|
||||
<input type="text" value={form.tax_number} onChange={e => updateField('tax_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
form.tag_ids.includes(tag.id)
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-100 text-text-secondary hover:bg-gray-200'
|
||||
}`}
|
||||
style={form.tag_ids.includes(tag.id) && tag.color ? { backgroundColor: tag.color, color: '#fff' } : undefined}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New tag name..."
|
||||
value={newTagName}
|
||||
onChange={e => setNewTagName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleCreateTag(); } }}
|
||||
className={`${inputClass} flex-1`}
|
||||
/>
|
||||
<button type="button" onClick={handleCreateTag} className="px-3 py-2 bg-surface border border-border rounded-lg text-sm hover:bg-gray-50">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Billing Address */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Billing Address</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Street</label>
|
||||
<input type="text" value={form.billing_street} onChange={e => updateField('billing_street', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Number</label>
|
||||
<input type="text" value={form.billing_number} onChange={e => updateField('billing_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input type="text" value={form.billing_postalcode} onChange={e => updateField('billing_postalcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input type="text" value={form.billing_city} onChange={e => updateField('billing_city', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<input type="text" value={form.billing_country} onChange={e => updateField('billing_country', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipping Address */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3 flex items-center gap-2">
|
||||
Shipping Address
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
shipping_street: prev.billing_street,
|
||||
shipping_number: prev.billing_number,
|
||||
shipping_postalcode: prev.billing_postalcode,
|
||||
shipping_city: prev.billing_city,
|
||||
shipping_country: prev.billing_country,
|
||||
}));
|
||||
}}
|
||||
className="text-xs text-primary underline font-normal"
|
||||
>
|
||||
Copy from billing
|
||||
</button>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Street</label>
|
||||
<input type="text" value={form.shipping_street} onChange={e => updateField('shipping_street', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Number</label>
|
||||
<input type="text" value={form.shipping_number} onChange={e => updateField('shipping_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Postal Code</label>
|
||||
<input type="text" value={form.shipping_postalcode} onChange={e => updateField('shipping_postalcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>City</label>
|
||||
<input type="text" value={form.shipping_city} onChange={e => updateField('shipping_city', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Country</label>
|
||||
<input type="text" value={form.shipping_country} onChange={e => updateField('shipping_country', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.note}
|
||||
onChange={e => updateField('note', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface CrewFormData {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role_title: string;
|
||||
hourly_rate: number | null;
|
||||
is_active: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyCrewFormData: CrewFormData = {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
role_title: '',
|
||||
hourly_rate: null,
|
||||
is_active: true,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface CrewFormProps {
|
||||
initialData?: CrewFormData;
|
||||
onSubmit: (data: CrewFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function CrewForm({ initialData, onSubmit, submitLabel = 'Save' }: CrewFormProps) {
|
||||
const [form, setForm] = useState<CrewFormData>(initialData || emptyCrewFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 crew member');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof CrewFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>First Name *</label>
|
||||
<input type="text" value={form.first_name} onChange={e => updateField('first_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Last Name *</label>
|
||||
<input type="text" value={form.last_name} onChange={e => updateField('last_name', e.target.value)}
|
||||
required className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input type="email" value={form.email} onChange={e => updateField('email', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input type="text" value={form.phone} onChange={e => updateField('phone', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Role / Title</label>
|
||||
<input type="text" value={form.role_title} onChange={e => updateField('role_title', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. Tontechniker" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Hourly Rate (€)</label>
|
||||
<input type="number" step="0.50" value={form.hourly_rate ?? ''}
|
||||
onChange={e => updateField('hourly_rate', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.is_active ? 'active' : 'inactive'}
|
||||
onChange={e => updateField('is_active', e.target.value === 'active')}
|
||||
className={inputClass}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => updateField('notes', e.target.value)}
|
||||
rows={3} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button type="submit" disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium">
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button type="button" onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LocationItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EquipmentFormData {
|
||||
name: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
serial_number: string;
|
||||
barcode: string;
|
||||
status: string;
|
||||
purchase_price: number | null;
|
||||
current_value: number | null;
|
||||
weight_kg: number | null;
|
||||
dimensions: string;
|
||||
power_watt: number | null;
|
||||
notes: string;
|
||||
location_id: string;
|
||||
}
|
||||
|
||||
export const emptyEquipmentFormData: EquipmentFormData = {
|
||||
name: '',
|
||||
category: '',
|
||||
brand: '',
|
||||
serial_number: '',
|
||||
barcode: '',
|
||||
status: 'available',
|
||||
purchase_price: null,
|
||||
current_value: null,
|
||||
weight_kg: null,
|
||||
dimensions: '',
|
||||
power_watt: null,
|
||||
notes: '',
|
||||
location_id: '',
|
||||
};
|
||||
|
||||
interface EquipmentFormProps {
|
||||
initialData?: EquipmentFormData;
|
||||
locations: LocationItem[];
|
||||
onSubmit: (data: EquipmentFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function EquipmentForm({ initialData, locations, onSubmit, submitLabel = 'Save' }: EquipmentFormProps) {
|
||||
const [form, setForm] = useState<EquipmentFormData>(initialData || emptyEquipmentFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 equipment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof EquipmentFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'available', label: 'Available' },
|
||||
{ value: 'rented', label: 'Rented' },
|
||||
{ value: 'maintenance', label: 'In Maintenance' },
|
||||
{ value: 'retired', label: 'Retired' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Equipment Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => updateField('name', e.target.value)}
|
||||
required className={inputClass} placeholder="e.g. LED Par 64" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Category</label>
|
||||
<input type="text" value={form.category} onChange={e => updateField('category', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. Lighting, Audio" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Brand</label>
|
||||
<input type="text" value={form.brand} onChange={e => updateField('brand', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Serial Number</label>
|
||||
<input type="text" value={form.serial_number} onChange={e => updateField('serial_number', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Barcode</label>
|
||||
<input type="text" value={form.barcode} onChange={e => updateField('barcode', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.status} onChange={e => updateField('status', e.target.value)}
|
||||
className={inputClass}>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Location</label>
|
||||
<select value={form.location_id} onChange={e => updateField('location_id', e.target.value)}
|
||||
className={inputClass}>
|
||||
<option value="">No location</option>
|
||||
{locations.map(loc => (
|
||||
<option key={loc.id} value={loc.id}>{loc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial & Physical */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Financial & Physical Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Purchase Price (€)</label>
|
||||
<input type="number" step="0.01" value={form.purchase_price ?? ''}
|
||||
onChange={e => updateField('purchase_price', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Current Value (€)</label>
|
||||
<input type="number" step="0.01" value={form.current_value ?? ''}
|
||||
onChange={e => updateField('current_value', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Weight (kg)</label>
|
||||
<input type="number" step="0.1" value={form.weight_kg ?? ''}
|
||||
onChange={e => updateField('weight_kg', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Dimensions</label>
|
||||
<input type="text" value={form.dimensions} onChange={e => updateField('dimensions', e.target.value)}
|
||||
className={inputClass} placeholder="e.g. 50x30x20 cm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Power (Watt)</label>
|
||||
<input type="number" step="1" value={form.power_watt ?? ''}
|
||||
onChange={e => updateField('power_watt', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => updateField('notes', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api.ts';
|
||||
import {
|
||||
FolderOpen,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FunctionGroupItem {
|
||||
id: string;
|
||||
name: string;
|
||||
project_id: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface FunctionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
function_group_id: string;
|
||||
quantity: number;
|
||||
daily_costs: number;
|
||||
total_costs: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface FunctionGroupEditorProps {
|
||||
projectId: string;
|
||||
functionGroups: FunctionGroupItem[];
|
||||
functionsMap: Record<string, FunctionItem[]>;
|
||||
onReload: () => void;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export default function FunctionGroupEditor({
|
||||
projectId,
|
||||
functionGroups,
|
||||
functionsMap,
|
||||
onReload,
|
||||
canWrite,
|
||||
canDelete,
|
||||
}: FunctionGroupEditorProps) {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editingGroupName, setEditingGroupName] = useState('');
|
||||
const [editingFuncId, setEditingFuncId] = useState<string | null>(null);
|
||||
const [editingFuncData, setEditingFuncData] = useState<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
daily_costs: number;
|
||||
total_costs: number;
|
||||
} | null>(null);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [showNewGroup, setShowNewGroup] = useState(false);
|
||||
const [newFuncMap, setNewFuncMap] = useState<Record<string, boolean>>({});
|
||||
const [newFuncData, setNewFuncData] = useState<Record<string, {
|
||||
name: string;
|
||||
quantity: number;
|
||||
daily_costs: number;
|
||||
total_costs: number;
|
||||
}>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const toggleExpand = (fgId: string) => {
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fgId)) {
|
||||
next.delete(fgId);
|
||||
} else {
|
||||
next.add(fgId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const startEditGroup = (fg: FunctionGroupItem) => {
|
||||
setEditingGroupId(fg.id);
|
||||
setEditingGroupName(fg.name);
|
||||
};
|
||||
|
||||
const cancelEditGroup = () => {
|
||||
setEditingGroupId(null);
|
||||
setEditingGroupName('');
|
||||
};
|
||||
|
||||
const saveGroup = async (fgId: string) => {
|
||||
if (!editingGroupName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/projects/${projectId}/function-groups/${fgId}`, {
|
||||
name: editingGroupName.trim(),
|
||||
});
|
||||
setEditingGroupId(null);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to update function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGroup = async (fgId: string) => {
|
||||
if (!confirm('Delete this function group and all its functions?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.delete(`/projects/${projectId}/function-groups/${fgId}`);
|
||||
// Also remove from expanded set
|
||||
setExpandedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(fgId);
|
||||
return next;
|
||||
});
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditFunc = (fn: FunctionItem) => {
|
||||
setEditingFuncId(fn.id);
|
||||
setEditingFuncData({
|
||||
name: fn.name,
|
||||
quantity: fn.quantity,
|
||||
daily_costs: fn.daily_costs,
|
||||
total_costs: fn.total_costs,
|
||||
});
|
||||
};
|
||||
|
||||
const cancelEditFunc = () => {
|
||||
setEditingFuncId(null);
|
||||
setEditingFuncData(null);
|
||||
};
|
||||
|
||||
const saveFunc = async (fgId: string, funcId: string) => {
|
||||
if (!editingFuncData || !editingFuncData.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/projects/${projectId}/function-groups/${fgId}/functions/${funcId}`, {
|
||||
name: editingFuncData.name.trim(),
|
||||
quantity: editingFuncData.quantity,
|
||||
daily_costs: editingFuncData.daily_costs,
|
||||
total_costs: editingFuncData.total_costs,
|
||||
});
|
||||
setEditingFuncId(null);
|
||||
setEditingFuncData(null);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to update function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFunc = async (fgId: string, funcId: string) => {
|
||||
if (!confirm('Delete this function?')) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.delete(`/projects/${projectId}/function-groups/${fgId}/functions/${funcId}`);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to delete function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addNewGroup = async () => {
|
||||
if (!newGroupName.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/function-groups`, {
|
||||
name: newGroupName.trim(),
|
||||
sort_order: functionGroups.length,
|
||||
});
|
||||
setNewGroupName('');
|
||||
setShowNewGroup(false);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create function group');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showNewFunc = (fgId: string) => {
|
||||
setNewFuncMap(prev => ({ ...prev, [fgId]: true }));
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fgId]: { name: '', quantity: 1, daily_costs: 0, total_costs: 0 },
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelNewFunc = (fgId: string) => {
|
||||
setNewFuncMap(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fgId];
|
||||
return next;
|
||||
});
|
||||
setNewFuncData(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[fgId];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const saveNewFunc = async (fgId: string) => {
|
||||
const data = newFuncData[fgId];
|
||||
if (!data || !data.name.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post(`/projects/${projectId}/function-groups/${fgId}/functions`, {
|
||||
name: data.name.trim(),
|
||||
quantity: data.quantity,
|
||||
daily_costs: data.daily_costs,
|
||||
total_costs: data.total_costs,
|
||||
sort_order: (functionsMap[fgId] || []).length,
|
||||
});
|
||||
cancelNewFunc(fgId);
|
||||
onReload();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.detail || 'Failed to create function');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full px-2 py-1 border border-border rounded focus:ring-2 focus:ring-primary outline-none text-sm';
|
||||
const labelClass = 'text-xs text-text-secondary';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{canWrite && (
|
||||
<div className="flex justify-end">
|
||||
{showNewGroup ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={e => setNewGroupName(e.target.value)}
|
||||
placeholder="New function group name..."
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') addNewGroup();
|
||||
if (e.key === 'Escape') {
|
||||
setShowNewGroup(false);
|
||||
setNewGroupName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={addNewGroup}
|
||||
disabled={saving || !newGroupName.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewGroup(false);
|
||||
setNewGroupName('');
|
||||
}}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowNewGroup(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Function Group
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{functionGroups.length === 0 && !showNewGroup && (
|
||||
<p className="text-text-secondary text-sm">No function groups defined yet.</p>
|
||||
)}
|
||||
|
||||
{functionGroups.map(fg => {
|
||||
const isExpanded = expandedGroups.has(fg.id);
|
||||
const funcs = functionsMap[fg.id] || [];
|
||||
const isEditingGroup = editingGroupId === fg.id;
|
||||
const isAddingFunc = newFuncMap[fg.id];
|
||||
|
||||
return (
|
||||
<div key={fg.id} className="bg-surface rounded-lg border border-border overflow-hidden">
|
||||
{/* Group Header */}
|
||||
<div className="px-4 py-3 bg-gray-50 border-b border-border flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(fg.id)}
|
||||
className="flex items-center gap-1 text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
<FolderOpen size={18} className="text-primary flex-shrink-0" />
|
||||
|
||||
{isEditingGroup ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingGroupName}
|
||||
onChange={e => setEditingGroupName(e.target.value)}
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveGroup(fg.id);
|
||||
if (e.key === 'Escape') cancelEditGroup();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveGroup(fg.id)}
|
||||
disabled={saving || !editingGroupName.trim()}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-primary text-white rounded hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditGroup}
|
||||
className="flex items-center gap-1 px-2 py-1 border border-border rounded hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-medium text-text-primary text-sm flex-1">{fg.name}</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-text-secondary">Sort: {fg.sort_order}</span>
|
||||
|
||||
{(canWrite || canDelete) && !isEditingGroup && (
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => startEditGroup(fg)}
|
||||
className="p-1 text-text-secondary hover:text-primary transition-colors"
|
||||
title="Edit group name"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteGroup(fg.id)}
|
||||
className="p-1 text-text-secondary hover:text-red-600 transition-colors"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Functions List (accordion body) */}
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{funcs.length === 0 && !isAddingFunc && (
|
||||
<p className="px-4 py-3 text-sm text-text-secondary">No functions in this group.</p>
|
||||
)}
|
||||
|
||||
{funcs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{funcs.map(fn => {
|
||||
const isEditingFunc = editingFuncId === fn.id;
|
||||
return (
|
||||
<div
|
||||
key={fn.id}
|
||||
className="px-4 py-3 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
{isEditingFunc && editingFuncData ? (
|
||||
<div className="flex-1 grid grid-cols-4 gap-2 items-end">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingFuncData.name}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev ? { ...prev, name: e.target.value } : prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') saveFunc(fg.id, fn.id);
|
||||
if (e.key === 'Escape') cancelEditFunc();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={editingFuncData.quantity}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? { ...prev, quantity: parseFloat(e.target.value) || 0 }
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Daily €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingFuncData.daily_costs}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
daily_costs: parseFloat(e.target.value) || 0,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Total €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingFuncData.total_costs}
|
||||
onChange={e =>
|
||||
setEditingFuncData(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
total_costs: parseFloat(e.target.value) || 0,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4 flex items-center gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => saveFunc(fg.id, fn.id)}
|
||||
disabled={saving || !editingFuncData.name.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditFunc}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<ListChecks size={16} className="text-text-secondary flex-shrink-0" />
|
||||
<div className="grid grid-cols-4 gap-4 flex-1">
|
||||
<div>
|
||||
<p className="text-sm text-text-primary">{fn.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Qty: {fn.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Daily: €{fn.daily_costs.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">
|
||||
Total: €{fn.total_costs.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{(canWrite || canDelete) && (
|
||||
<>
|
||||
{canWrite && (
|
||||
<button
|
||||
onClick={() => startEditFunc(fn)}
|
||||
className="p-1 text-text-secondary hover:text-primary transition-colors"
|
||||
title="Edit function"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => deleteFunc(fg.id, fn.id)}
|
||||
className="p-1 text-text-secondary hover:text-red-600 transition-colors"
|
||||
title="Delete function"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-text-secondary ml-1">
|
||||
#{fn.sort_order}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add new function form */}
|
||||
{isAddingFunc && (
|
||||
<div className="px-4 py-3 border-t border-border bg-gray-50">
|
||||
<div className="grid grid-cols-4 gap-2 items-end mb-2">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newFuncData[fg.id]?.name || ''}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
name: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Function name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Qty</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
value={newFuncData[fg.id]?.quantity || 1}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
quantity: parseFloat(e.target.value) || 1,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Daily €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newFuncData[fg.id]?.daily_costs || 0}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
daily_costs: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Total €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newFuncData[fg.id]?.total_costs || 0}
|
||||
onChange={e =>
|
||||
setNewFuncData(prev => ({
|
||||
...prev,
|
||||
[fg.id]: {
|
||||
...prev[fg.id],
|
||||
total_costs: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => saveNewFunc(fg.id)}
|
||||
disabled={saving || !newFuncData[fg.id]?.name?.trim()}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-xs disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => cancelNewFunc(fg.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-surface border border-border rounded-lg hover:bg-gray-50 text-xs"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && !isAddingFunc && (
|
||||
<div className="px-4 py-2 border-t border-border">
|
||||
<button
|
||||
onClick={() => showNewFunc(fg.id)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary-dark transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Function
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface ProjectFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
budget: number | null;
|
||||
total_costs: number | null;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyProjectFormData: ProjectFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
status: 'draft',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
budget: null,
|
||||
total_costs: null,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface ProjectFormProps {
|
||||
initialData?: ProjectFormData;
|
||||
onSubmit: (data: ProjectFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function ProjectForm({ initialData, onSubmit, submitLabel = 'Save' }: ProjectFormProps) {
|
||||
const [form, setForm] = useState<ProjectFormData>(initialData || emptyProjectFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof ProjectFormData, value: any) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'confirmed', label: 'Confirmed' },
|
||||
{ value: 'in_progress', label: 'In Progress' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Project Information</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => updateField('name', e.target.value)}
|
||||
required className={inputClass} placeholder="e.g. Summer Festival 2026" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Description</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={e => updateField('description', e.target.value)}
|
||||
rows={3}
|
||||
className={inputClass}
|
||||
placeholder="Project description..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.status} onChange={e => updateField('status', e.target.value)}
|
||||
className={inputClass}>
|
||||
{statusOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Budget (€)</label>
|
||||
<input type="number" step="0.01" value={form.budget ?? ''}
|
||||
onChange={e => updateField('budget', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Start Date</label>
|
||||
<input type="datetime-local" value={form.start_date}
|
||||
onChange={e => updateField('start_date', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>End Date</label>
|
||||
<input type="datetime-local" value={form.end_date}
|
||||
onChange={e => updateField('end_date', e.target.value)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Financial Details</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Total Costs (€)</label>
|
||||
<input type="number" step="0.01" value={form.total_costs ?? ''}
|
||||
onChange={e => updateField('total_costs', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className={sectionClass}>
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-3">Notes</h3>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={e => updateField('notes', e.target.value)}
|
||||
rows={4}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.history.back()}
|
||||
className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore.ts';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
requiredPermission?: string;
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({ requiredPermission }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user } = useAuthStore();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Check permission if required
|
||||
if (requiredPermission && user) {
|
||||
const hasPermission = user.permissions?.includes(requiredPermission);
|
||||
if (!hasPermission) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-text-primary mb-2">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-text-secondary">
|
||||
You don't have permission to access this page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export interface VehicleFormData {
|
||||
name: string;
|
||||
license_plate: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
year: number | null;
|
||||
color: string;
|
||||
vehicle_type: string;
|
||||
payload_capacity_kg: number | null;
|
||||
load_volume_m3: number | null;
|
||||
fuel_type: string;
|
||||
is_active: boolean;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export const emptyVehicleFormData: VehicleFormData = {
|
||||
name: '',
|
||||
license_plate: '',
|
||||
brand: '',
|
||||
model: '',
|
||||
year: null,
|
||||
color: '',
|
||||
vehicle_type: '',
|
||||
payload_capacity_kg: null,
|
||||
load_volume_m3: null,
|
||||
fuel_type: '',
|
||||
is_active: true,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
interface VehicleFormProps {
|
||||
initialData?: VehicleFormData;
|
||||
onSubmit: (data: VehicleFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export default function VehicleForm({ initialData, onSubmit, submitLabel = 'Save' }: VehicleFormProps) {
|
||||
const [form, setForm] = useState<VehicleFormData>(initialData || emptyVehicleFormData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
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 vehicle'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const u = (field: keyof VehicleFormData, value: any) => setForm(p => ({ ...p, [field]: value }));
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && <div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className={labelClass}>Vehicle Name *</label>
|
||||
<input type="text" value={form.name} onChange={e => u('name', e.target.value)} required className={inputClass} placeholder="e.g. Sprinter 315 CDI" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>License Plate</label>
|
||||
<input type="text" value={form.license_plate} onChange={e => u('license_plate', e.target.value)} className={inputClass} placeholder="e.g. GÜ-AB 1234" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Brand</label>
|
||||
<input type="text" value={form.brand} onChange={e => u('brand', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Model</label>
|
||||
<input type="text" value={form.model} onChange={e => u('model', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Year</label>
|
||||
<input type="number" value={form.year ?? ''} onChange={e => u('year', e.target.value ? parseInt(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Color</label>
|
||||
<input type="text" value={form.color} onChange={e => u('color', e.target.value)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Vehicle Type</label>
|
||||
<select value={form.vehicle_type} onChange={e => u('vehicle_type', e.target.value)} className={inputClass}>
|
||||
<option value="">Select type...</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="truck">Truck</option>
|
||||
<option value="trailer">Trailer</option>
|
||||
<option value="car">Car</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Fuel Type</label>
|
||||
<select value={form.fuel_type} onChange={e => u('fuel_type', e.target.value)} className={inputClass}>
|
||||
<option value="">Select...</option>
|
||||
<option value="diesel">Diesel</option>
|
||||
<option value="petrol">Petrol</option>
|
||||
<option value="electric">Electric</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Payload Capacity (kg)</label>
|
||||
<input type="number" step="10" value={form.payload_capacity_kg ?? ''} onChange={e => u('payload_capacity_kg', e.target.value ? parseFloat(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Load Volume (m³)</label>
|
||||
<input type="number" step="0.5" value={form.load_volume_m3 ?? ''} onChange={e => u('load_volume_m3', e.target.value ? parseFloat(e.target.value) : null)} className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Status</label>
|
||||
<select value={form.is_active ? 'active' : 'inactive'} onChange={e => u('is_active', e.target.value === 'active')} className={inputClass}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Notes</label>
|
||||
<textarea value={form.notes} onChange={e => u('notes', e.target.value)} rows={3} className={inputClass} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4 border-t border-border">
|
||||
<button type="submit" disabled={loading} className="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark disabled:opacity-50 font-medium">
|
||||
{loading ? 'Saving...' : submitLabel}
|
||||
</button>
|
||||
<button type="button" onClick={() => window.history.back()} className="px-4 py-2 bg-surface border border-border rounded-lg hover:bg-gray-50 text-sm">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user