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:
Agent Zero
2026-05-31 20:36:42 +00:00
commit 7f7da15965
135 changed files with 18980 additions and 0 deletions
+24
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
# Project Name
## Description
What this project does.
## Quick Start
How to get started quickly.
## Documentation
Links to key documentation files.
+273
View File
@@ -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.
+20
View File
@@ -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
+160
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,14 @@
# Environment Variables
## Required
| Variable | Description | Default |
|---|---|---|
## Optional
| Variable | Description | Default |
|---|---|---|
## .env.example
```
# Copy to .env and fill in values
```
+12
View File
@@ -0,0 +1,12 @@
# Healthcheck
## Endpoint
## Expected Response
## How to Check
```bash
curl http://localhost:PORT/health
```
## Troubleshooting
+20
View File
@@ -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>
```
+36
View File
@@ -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)
+21
View File
@@ -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"
]
}
+80
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+241
View File
@@ -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 (02)
| Phase | Tasks | Inhalt |
|-------|-------|--------|
| 0 Foundation | T001T003 | Projektstruktur, Docker Compose, FastAPI-Grundgerüst, React-Frontend mit Vite/Tailwind/Router/Zustand |
| 1 Auth-System | T004T008 | Account/User/Role-Modelle, JWT-Auth (Login/Register/Refresh), RBAC-Middleware, Admin-API für User & Roles, Login/Register/Admin-UI |
| 2 Contacts & Tags | T009T011 | 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:** T012T020 (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! 🚀**
+9
View File
@@ -0,0 +1,9 @@
# Rollback
## Pre-requisites
## Procedure
## Verification
## Emergency Contacts
+13
View File
@@ -0,0 +1,13 @@
# Runbook
## Start
## Stop
## Restart
## Logs
## Common Issues
## Monitoring
+15
View File
@@ -0,0 +1,15 @@
# Runtime Report
## Date
## Application Status
## Logs Summary
## Healthcheck
## Browser Smoke Test
## Errors Found
## Recommendations
+482
View File
@@ -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"
}
]
+14
View File
@@ -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**: ...
+13
View File
@@ -0,0 +1,13 @@
# Test Report
## Date
## Test Commands
## Results
## Failures
## Coverage
## Known Issues
+39
View File
@@ -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)
+29
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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
+113
View File
@@ -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
+23
View File
@@ -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"]
+119
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+69
View File
@@ -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())
+26
View File
@@ -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 ###
View File
View File
+99
View File
@@ -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
View File
+193
View File
@@ -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)
+220
View File
@@ -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
+269
View File
@@ -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
+193
View File
@@ -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
+208
View File
@@ -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
+29
View File
@@ -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,
}
+674
View File
@@ -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
+88
View File
@@ -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)
+33
View File
@@ -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"])
+142
View File
@@ -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
+59
View File
@@ -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)
+161
View File
@@ -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
+208
View File
@@ -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
View File
+50
View File
@@ -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()
+46
View File
@@ -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])
View File
+8
View File
@@ -0,0 +1,8 @@
"""SQLAlchemy declarative base."""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
"""Base class for all database models."""
pass
+31
View File
@@ -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()
+49
View File
@@ -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"}
+32
View File
@@ -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",
]
+34
View File
@@ -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")
+85
View File
@@ -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
+88
View File
@@ -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}>"
+70
View File
@@ -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})>"
+74
View File
@@ -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}>"
+149
View File
@@ -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}>"
+28
View File
@@ -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")
+47
View File
@@ -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}>"
+34
View File
@@ -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"
)
+39
View File
@@ -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")
+95
View File
@@ -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}>"
+52
View File
@@ -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}
+101
View File
@@ -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
+94
View File
@@ -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
+95
View File
@@ -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
+58
View File
@@ -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
+170
View File
@@ -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
+28
View File
@@ -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}
+39
View File
@@ -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
+40
View File
@@ -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
+107
View File
@@ -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
+13
View File
@@ -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
+65
View File
@@ -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:
+15
View File
@@ -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"]
+13
View File
@@ -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>
+4616
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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"
}
}
+143
View File
@@ -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>
);
}
+136
View File
@@ -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>
);
}
+367
View File
@@ -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>
);
}
+123
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+163
View File
@@ -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 />;
}
+138
View File
@@ -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