commit aa8abf4e569eebcf607c13663c33340abbb6e512 Author: athul rd <49590824+athulrd@users.noreply.github.com> Date: Tue Jul 28 08:37:05 2026 +0530 Initial commit: MV Rent car rental management system Spring Boot + Thymeleaf app: fleet, drivers, vendors, bookings, billing/ settlements, payments, maintenance, inspections, reviews, telematics, audit log, staff/user management, and separate admin/vendor/driver portals. Includes Docker Compose deployment (app + Postgres). Co-Authored-By: Claude Opus 4.8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..196505c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +target +.git +.gitignore +uploads +*.log +.idea +.vscode +.DS_Store +docs +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5b56f78 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Copy to `.env` on the server and fill in real values. NEVER commit the real .env. + +# ---- Database (required) ---- +DB_USER=postgres +DB_PASSWORD=change-me-to-a-strong-password + +# ---- Email notifications (optional) ---- +MAIL_ENABLED=false +MAIL_HOST= +MAIL_PORT=587 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=no-reply@mvrent.com +MAIL_ADMIN=admin@mvrent.com + +# ---- GPS telematics ingest key (optional) ---- +TELEMATICS_API_KEY= + +# ---- Google OAuth2 login (optional) ---- +GOOGLE_CLIENT_ID=not-configured +GOOGLE_CLIENT_SECRET=not-configured diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac75f54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +### Secrets / environment (NEVER commit real .env) ### +.env +.env.* +!.env.example + +target/ +*.jar +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ ### +.idea +*.iws +*.iml +*.ipr + +### VS Code ### +.vscode/ +.DS_Store + +### Logs ### +*.log + +### Uploaded files (configurable storage dir) ### +uploads/ diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..869904a --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,121 @@ +# Deploying MV Rent to a server (Docker Compose) + +Target: `root@93.127.166.135`. The stack runs the Spring Boot app + PostgreSQL in +containers. The server only needs Docker — no Java or Postgres install required. + +--- + +## 1. Install Docker on the server (one time) + +SSH in and install Docker + the Compose plugin: + +```bash +ssh root@93.127.166.135 + +# Ubuntu/Debian: +curl -fsSL https://get.docker.com | sh +docker --version && docker compose version +``` + +Open the firewall for HTTP (and HTTPS if you'll add TLS later): + +```bash +ufw allow 80/tcp && ufw allow 443/tcp # skip if ufw isn't enabled +``` + +## 2. Copy the project to the server + +From your **local machine** (in the project folder `car-rent`): + +```bash +# excludes build output, .git and local uploads +rsync -az --delete \ + --exclude target --exclude .git --exclude uploads \ + ./ root@93.127.166.135:/opt/mvrent/ +``` + +(Or `git clone` your repo into `/opt/mvrent` if it's pushed somewhere.) + +## 3. Configure secrets + +On the **server**: + +```bash +cd /opt/mvrent +cp .env.example .env +nano .env # set a strong DB_PASSWORD (and mail creds if you want email) +``` + +## 4. Build & start + +```bash +cd /opt/mvrent +docker compose up -d --build +``` + +First run takes a few minutes (it builds the app image and downloads Postgres). +On startup the app creates the schema and seeds demo data automatically. + +## 5. Verify + +```bash +docker compose ps # both services "running"/"healthy" +docker compose logs -f app # look for "Started CarRentalApplication" +curl -I http://localhost # expect HTTP 200/302 +``` + +Then open **http://93.127.166.135/** in a browser. + +| Area | URL | Default login | +|------|-----|---------------| +| Public site | `/` | — | +| Admin | `/admin/login` | `admin` / `admin123` | +| Vendor portal | `/vendor/login` | created from admin | +| Driver portal | `/driver/login` | created from admin | + +> ⚠️ **Change the admin password immediately** (Admin → profile) and delete the +> demo customer if this is production. + +--- + +## Day-2 operations + +**Update after code changes** (re-run rsync from step 2, then): +```bash +cd /opt/mvrent && docker compose up -d --build +``` + +**Logs / restart / stop:** +```bash +docker compose logs -f app +docker compose restart app +docker compose down # stop (keeps data volumes) +``` + +**Back up the database:** +```bash +docker compose exec db pg_dump -U postgres mv_rent > backup-$(date +%F).sql +``` + +**Restore:** +```bash +cat backup-YYYY-MM-DD.sql | docker compose exec -T db psql -U postgres -d mv_rent +``` + +Uploaded files persist in the `uploads` Docker volume; DB in `db-data`. + +--- + +## Optional: domain + HTTPS (nginx + Let's Encrypt) + +If you point a domain at the server, put nginx in front and get a free cert. +First change the app's port mapping in `docker-compose.yml` from `"80:8080"` to +`"127.0.0.1:8080:8080"` (so only nginx is public), then: + +```bash +apt install -y nginx certbot python3-certbot-nginx +# create /etc/nginx/sites-available/mvrent -> proxy_pass http://127.0.0.1:8080; +certbot --nginx -d yourdomain.com +``` + +Ask me and I'll generate the exact nginx server block for your domain. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7e0f2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# ---------- Build stage ---------- +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build +# Cache dependencies first +COPY pom.xml . +RUN mvn -B -q dependency:go-offline +# Build the app +COPY src ./src +RUN mvn -B -q -DskipTests clean package + +# ---------- Runtime stage ---------- +FROM eclipse-temurin:17-jre +WORKDIR /app +# Uploaded documents/photos live here (mounted as a volume) +ENV STORAGE_DIR=/data/uploads +RUN mkdir -p /data/uploads +COPY --from=build /build/target/car-rental-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4059ed --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# EVM Wheels — Car Rental Management + +A Spring Boot 3 + Thymeleaf + PostgreSQL web app for managing a self-drive car rental business. + +## Features + +- **Public site** — landing page with hero, search bar, and featured cars +- **Browse & detail** — `/cars` (with optional date-range availability filter) and `/cars/{id}` +- **Auth** — username/password registration & login (Spring Security + BCrypt) +- **Customer flow** — book a car, view your bookings at `/my-bookings` +- **Admin panel** — `/admin` dashboard, full CRUD for cars, manage all bookings & update status +- **Date-range conflict detection** — prevents double-booking the same car + +## Stack + +| Layer | Choice | +|----------|--------------------------------| +| Runtime | Java 17, Spring Boot 3.3 | +| Web | Spring MVC + Thymeleaf | +| Data | Spring Data JPA + Hibernate | +| DB | PostgreSQL | +| Security | Spring Security 6 + BCrypt | +| Build | Maven | + +## Prerequisites + +- JDK 17+ +- Maven 3.9+ +- PostgreSQL 13+ running locally + +## Database setup + +```sql +CREATE DATABASE evm_wheels; +``` + +By default the app connects with `postgres` / `postgres` on `localhost:5432/evm_wheels`. Override via env vars or by editing [src/main/resources/application.properties](src/main/resources/application.properties): + +```bash +export DB_URL=jdbc:postgresql://localhost:5432/evm_wheels +export DB_USER=postgres +export DB_PASSWORD=postgres +``` + +Hibernate's `ddl-auto=update` creates the schema on first run, and `data.sql` seeds 6 demo cars. Users are seeded from [DataInitializer.java](src/main/java/com/evmwheels/carrental/config/DataInitializer.java) using the real password encoder. + +## Run + +```bash +mvn spring-boot:run +``` + +Then open . + +## Seeded accounts + +| Role | Username | Password | +|----------|----------|------------| +| Admin | `admin` | `admin123` | +| Customer | `demo` | `demo1234` | + +Change these immediately if you deploy this anywhere real. + +## Project layout + +``` +src/main/java/com/evmwheels/carrental/ +├── CarRentalApplication.java +├── config/ SecurityConfig, DataInitializer +├── controller/ Home, Car, Booking, Auth, Admin +├── model/ Car, User, Booking + enums +├── repository/ Spring Data JPA interfaces +└── service/ Business logic (booking conflict checks, etc.) + +src/main/resources/ +├── application.properties +├── data.sql seed cars +├── static/{css,images}/ assets +└── templates/ Thymeleaf views +``` + +## Routes + +| Method | Path | Access | +|--------|----------------------------------|------------| +| GET | `/` | public | +| GET | `/cars`, `/cars/{id}` | public | +| GET/POST | `/login`, `/register` | public | +| GET/POST | `/cars/{id}/book` | customer | +| GET | `/my-bookings` | customer | +| GET | `/admin` | admin | +| GET/POST | `/admin/cars`, `/admin/cars/**` | admin | +| GET | `/admin/bookings` | admin | +| POST | `/admin/bookings/{id}/status` | admin | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a7d0cca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + db: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_DB: mv_rent + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d mv_rent"] + interval: 10s + timeout: 5s + retries: 6 + + app: + build: . + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DB_URL: jdbc:postgresql://db:5432/mv_rent + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD} + STORAGE_DIR: /data/uploads + # Optional integrations — safe defaults; override in .env to activate + MAIL_ENABLED: ${MAIL_ENABLED:-false} + MAIL_HOST: ${MAIL_HOST:-} + MAIL_PORT: ${MAIL_PORT:-587} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + MAIL_FROM: ${MAIL_FROM:-no-reply@mvrent.local} + MAIL_ADMIN: ${MAIL_ADMIN:-admin@mvrent.local} + TELEMATICS_API_KEY: ${TELEMATICS_API_KEY:-} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-not-configured} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-not-configured} + volumes: + - uploads:/data/uploads + ports: + # host:container — default 80 on the server; override HTTP_PORT locally + - "${HTTP_PORT:-80}:8080" + +volumes: + db-data: + uploads: diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..caf3fca --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,232 @@ +# Car Rental Management System — Implementation Plan + +> Status: **DRAFT for review** · Date: 2026-06-26 +> Baseline: Spring Boot 3.3.4 · Java 17 · Thymeleaf · PostgreSQL · Spring Security (admin + customer chains, Google OAuth2) +> Purpose: turn the current customer-booking + admin-CRUD app into the full multi-party platform described in the feature spec. + +--- + +## 1. Scope summary + +The current app covers ~25–30% of the spec (customer-facing booking + basic admin car/booking CRUD). +This plan adds the four missing pillars — **Driver, Vendor, Billing/Settlement, Reporting** — plus the +**Organization/config** foundation and enrichment of Fleet, Customer, and Booking. + +Current coverage vs target: + +| Area | Now | Target | +|---|---|---| +| Organization / Config | ~20% | Org profile, staff users + permissions, tax/currency/commission config, category masters | +| Fleet & Car | ~45% | + year, colour, ownership, feature tags, hourly/monthly rates, documents+expiry, maintenance | +| Driver | 0% | full module | +| Vendor | 0% | full module + vendor login + settlement | +| Customer | ~50% | + categories (Individual/Company/Corporate), GST/billing | +| Trip Booking | ~40% | + with-driver, hours/months, tax & driver charges, invoice | +| Billing/Settlement | ~5% | invoices, commission, settlements, profit split | +| Reporting | 0% | filtered reports + Excel/PDF export, dashboards | +| Customer page | ~55% | richer filters + driver option | + +--- + +## 2. Target architecture + +- **Single Spring Boot modular-monolith** (no microservices). Package-by-feature: + `org`, `fleet`, `driver`, `vendor`, `customer`, `booking`, `billing`, `reporting`, `web`, `security`, `config`. +- **Three security filter chains** (extends today's pattern): + 1. `/admin/**` → staff (ROLE_ADMIN, ROLE_STAFF) — existing admin chain, expanded + 2. `/vendor/**` → ROLE_VENDOR — **new** vendor portal chain + 3. everything else → customers + Google OAuth (existing customer chain) +- **Persistence**: PostgreSQL + JPA. Move `ddl-auto=update` → **Flyway migrations** (versioned schema; required once money/settlement tables exist). +- **Money**: always `BigDecimal`, scale 2, currency from config. Never `double`. +- **Exports**: Apache POI (Excel), OpenPDF or Flying Saucer (HTML→PDF) for invoices/statements/reports. +- **Server-side rendering** stays Thymeleaf; add small JS for dynamic price calc on the customer page. + +--- + +## 3. Role & permission model + +Replace the 2-value `Role` enum with a richer model: + +``` +Role (enum): ADMIN, STAFF, VENDOR, CUSTOMER +Permission (enum): MANAGE_FLEET, MANAGE_DRIVERS, MANAGE_VENDORS, MANAGE_BOOKINGS, + MANAGE_BILLING, VIEW_REPORTS, MANAGE_CONFIG, MANAGE_USERS +``` + +- `ADMIN` = all permissions. `STAFF` = a configurable subset (stored per-user). +- `VENDOR` = scoped to own data only (own cars, own trips, own settlements). +- Authorization: method-level `@PreAuthorize("hasAuthority('MANAGE_FLEET')")` on services/controllers. + +--- + +## 4. Domain model (entities & key fields) + +New entities in **bold**; modified existing in _italics_. + +### 4.1 Organization / Config +- **Organization** — name, legalName, logoUrl, address, gstNumber, contactEmail, phone, currencyCode +- **SystemConfig** (singleton) — currencyCode, defaultTaxPercent, defaultCommissionPercent, + defaultDriverDailyCharge, invoicePrefix, settlementCycle (WEEKLY/MONTHLY) +- **Category** (generic master) — `type` (VEHICLE / DRIVER / CUSTOMER), name, description, active + → replaces hard-coded enums so categories are admin-configurable +- _User_ — add `permissions` (set), `vendor` (nullable FK for vendor-portal users) + +### 4.2 Fleet +- _Car_ — add: `yearOfRegistration`, `colour`, `ownership` (OWNED / VENDOR), `vendor` (nullable FK), + `hourlyRate`, `dailyRate` (rename pricePerDay), `monthlyRate`, `unitsTotal`, + `featureTags` (AC, GPS, … as a set/element-collection), `status` (ACTIVE / MAINTENANCE / RETIRED) +- **CarDocument** — car FK, docType (RC / INSURANCE / PERMIT / POLLUTION), fileUrl, issuedDate, expiryDate +- **CarPhoto** — car FK, url, sortOrder (gallery instead of single imageUrl) + +### 4.3 Driver +- **Driver** — name, phone, licenceNumber, licenceExpiry, yearsExperience, category (FK Category), + employmentType (EMPLOYED / VENDOR), vendor (nullable FK), hourlyCharge, dailyCharge, monthlyCharge, + status (AVAILABLE / ON_TRIP / OFF), photoUrl +- **DriverDocument** — driver FK, docType, fileUrl, expiryDate +- Trip history derived from `Booking` where `driver = X` + +### 4.4 Vendor +- **Vendor** — name, contactPerson, phone, email, address, gstNumber, + bankAccount/settlementDetails, commissionPercent (nullable → overrides global), active +- Vendor login = a `User` with ROLE_VENDOR linked to the Vendor +- **VendorSettlement** — vendor FK, periodStart, periodEnd, grossRevenue, commissionAmount, + driverCharges, netPayable, status (DRAFT / FINALISED / PAID), generatedAt + +### 4.5 Customer +- _User_ (customer) — add: `customerCategory` (INDIVIDUAL / COMPANY / CORPORATE), + `companyName`, `gstNumber`, `billingAddress`, `contactPersons` (for company/corporate) + +### 4.6 Booking & Billing +- _Booking_ — add: `driver` (nullable FK), `withDriver` (bool), `rentalUnit` (HOUR / DAY / MONTH), + `quantity` (units of the period), `vehicleCharge`, `driverCharge`, `taxPercent`, `taxAmount`, + `commissionPercent`, `commissionAmount`, `revenueOwner` (OWNED / VENDOR), `vendor` (nullable FK) +- **Invoice** — booking FK, invoiceNumber, issuedAt, lineItems, subtotal, tax, total, pdfUrl +- **CommissionRule** — scope (GLOBAL / VENDOR / VEHICLE), refId, percent + (resolution order: vehicle → vendor → global) + +### 4.7 ER overview (relationships) +``` +Organization 1─* User +User *─1 Vendor (vendor-portal users) Vendor 1─* Car +Vendor 1─* Driver Car 1─* CarDocument / CarPhoto +Category 1─* Car / Driver / User(customer) Driver 1─* DriverDocument +Customer(User) 1─* Booking Car 1─* Booking +Driver 1─* Booking (optional) Booking 1─1 Invoice +Vendor 1─* Booking (revenue attribution) Vendor 1─* VendorSettlement +``` + +--- + +## 5. Calculation rules (single source of truth) + +``` +periodCount = ceil(duration in chosen unit) // HOUR / DAY / MONTH +vehicleCharge = rate(unit) × periodCount × units +driverCharge = withDriver ? driverRate(unit) × periodCount : 0 +subtotal = vehicleCharge + driverCharge +taxAmount = subtotal × taxPercent/100 +total = subtotal + taxAmount + +// Vendor settlement (per vendor trip) +commissionPercent = resolve(vehicle → vendor → global) +commissionAmount = vehicleCharge × commissionPercent/100 +netVendorPayable = vehicleCharge − commissionAmount + (vendorDriverCharge − orgDriverMargin) + +// Owned vs vendor profit split tracked via Booking.revenueOwner +``` +All money `BigDecimal`, `RoundingMode.HALF_UP`, scale 2, currency from `SystemConfig`. + +--- + +## 6. Screen inventory + +**Admin / Staff (`/admin`)** +- Dashboard (KPIs + owned-vs-vendor split), Fleet (list/form + docs/photos/maintenance), + Drivers (list/form/profile + trip history), Vendors (list/form + settlements), + Customers (list/detail), Bookings (list/detail/assign-driver/invoice), + Billing & Settlements, Reports (filter + export), Settings (org, config, categories, staff & permissions) + +**Vendor portal (`/vendor`)** — new +- Login, Dashboard (own trips/revenue), My Cars, My Drivers, Trips (period filter), + Settlements (view + export PDF/Excel) + +**Customer page (public)** +- Home, Car listing **with filters** (category, date, seating, transmission, price, driver option) + live price, + Car detail, Booking (with/without driver + category), Register/Login, My Bookings + invoices + +--- + +## 7. Phased delivery plan + +Each phase is independently shippable and ends with a working app + tests. + +### Phase 0 — Foundations (enablers) +- Add Flyway; convert current schema to `V1__baseline.sql`; turn off `ddl-auto` +- Expand `Role` + add `Permission`; method security +- `Organization` + `SystemConfig` + `Category` master; Settings screens +- Replace hard-coded category enums with `Category` lookups (keep enums as seed data) + +### Phase 1 — Fleet enrichment +- Car: year, colour, ownership, feature tags, hourly/daily/monthly rates, units, status(maintenance) +- CarDocument + CarPhoto with expiry tracking + expiry warnings on dashboard +- Maintenance blocking in availability logic + +### Phase 2 — Driver module +- Driver entity + documents + categories + charges + availability +- Driver list/form/profile + trip history; driver-availability check + +### Phase 3 — Vendor module +- Vendor onboarding + ROLE_VENDOR login + vendor filter chain (`/vendor/**`) +- Vendor dashboard (own cars/drivers/trips); commission config (global/vendor/vehicle) + +### Phase 4 — Booking upgrades +- With/without driver + driver category assignment +- Hourly/daily/monthly periods; tax + driver-charge calc; revenue attribution +- Driver double-booking prevention; invoice generation (PDF) + +### Phase 5 — Billing & settlement +- Invoices/receipts for customers +- Vendor settlement aggregation per cycle + statement (Excel + PDF) + profit split + +### Phase 6 — Reporting +- Filtered reports (date, vehicle, category, driver, vendor, customer category, ownership, status) +- Excel + PDF export; summary dashboard (owned vs vendor); vendor-wise & driver-wise reports + +### Phase 7 — Customer page polish +- Full filter set + live cost-by-duration; with-driver booking flow; responsive QA + +--- + +## 8. Dependencies to add +- `flyway-core` + `flyway-database-postgresql` +- `org.apache.poi:poi-ooxml` (Excel) +- `com.github.librepdf:openpdf` **or** `org.xhtmlrenderer:flying-saucer-pdf` (PDF from Thymeleaf) +- (already present: web, security, oauth2-client, data-jpa, validation, thymeleaf, postgresql, lombok) + +--- + +## 9. Cross-cutting concerns +- **Migrations**: every schema change via Flyway; never edit a shipped migration. +- **Validation**: bean validation + show `th:errors` on all admin/vendor forms (current admin forms lack this). +- **Auditing**: `createdAt/updatedAt/createdBy` via JPA auditing on money-bearing entities. +- **Performance**: fix N+1 in booking/report queries with fetch-joins; add pagination to all admin tables. +- **Files**: documents/photos need storage — decide local disk vs S3-compatible (config-driven path). +- **Testing**: service-layer unit tests for all calc rules; `@WebMvcTest` for access control per chain. +- **Seed data**: keep `DataInitializer` for org/config/admin + demo vendor/driver/cars. + +--- + +## 10. Decisions (LOCKED — 2026-06-26, recommended defaults accepted) +1. **File storage** → **local filesystem** (config-driven path) for v1. +2. **PDF library** → **Flying Saucer** (Thymeleaf HTML/CSS → PDF). +3. **Tenancy** → **single organization** per install. +4. **Staff permissions** → **per-user permission checkboxes** (granular `Permission` set). +5. **Settlement cycle** → **monthly** default. +6. **Bookings** → **day/month** for v1 (hourly *rate fields* stored, hourly *booking flow* deferred). + +### Deviation from §7 +- **Flyway is deferred** to a later hardening phase. During active development the app keeps + `spring.jpa.hibernate.ddl-auto=update`, which additively creates the new tables/columns without + disrupting the running PostgreSQL database. Flyway baseline will be introduced before production. + +> Plan locked. Implementation started with Phase 0 — Foundations. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..058ff65 --- /dev/null +++ b/pom.xml @@ -0,0 +1,119 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.mvrent + car-rental + 0.0.1-SNAPSHOT + car-rental + MV Rent — Car Rental Management + + + 17 + + 1.18.40 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-starter-mail + + + + org.xhtmlrenderer + flying-saucer-pdf-openpdf + 9.1.22 + + + + org.apache.poi + poi-ooxml + 5.3.0 + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/mvrent/carrental/CarRentalApplication.java b/src/main/java/com/mvrent/carrental/CarRentalApplication.java new file mode 100644 index 0000000..8683b44 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/CarRentalApplication.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CarRentalApplication { + public static void main(String[] args) { + SpringApplication.run(CarRentalApplication.class, args); + } +} diff --git a/src/main/java/com/mvrent/carrental/config/AsyncConfig.java b/src/main/java/com/mvrent/carrental/config/AsyncConfig.java new file mode 100644 index 0000000..88899aa --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/AsyncConfig.java @@ -0,0 +1,10 @@ +package com.mvrent.carrental.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +/** Enables @Async so outbound email/SMS never blocks the request thread. */ +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/java/com/mvrent/carrental/config/AuditAspect.java b/src/main/java/com/mvrent/carrental/config/AuditAspect.java new file mode 100644 index 0000000..cb61ac2 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/AuditAspect.java @@ -0,0 +1,78 @@ +package com.mvrent.carrental.config; + +import com.mvrent.carrental.service.AuditService; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; + +/** + * Automatically records every mutating service-layer call (save / create / + * update / delete / add / remove / generate) to the audit trail. Argument + * values are never logged (to avoid leaking passwords etc.) — only the + * action, entity type and id. + */ +@Aspect +@Component +@RequiredArgsConstructor +public class AuditAspect { + + private final AuditService auditService; + + @Pointcut("execution(* com.mvrent.carrental.service.*.*(..))") + void serviceLayer() {} + + @AfterReturning(pointcut = "serviceLayer()", returning = "result") + public void afterMutation(JoinPoint jp, Object result) { + String method = jp.getSignature().getName(); + String action = mapAction(method); + if (action == null) return; + + String cls = jp.getSignature().getDeclaringType().getSimpleName(); + // Avoid recursion / noise from the auditing beans themselves. + if (cls.equals("AuditService") || cls.equals("AuditWriter")) return; + + String entityType = cls.endsWith("Service") ? cls.substring(0, cls.length() - "Service".length()) : cls; + String entityId = extractId(result, jp.getArgs()); + auditService.log(action, entityType, entityId, method); + } + + private static String mapAction(String m) { + if (m.startsWith("delete") || m.startsWith("remove")) return "DELETE"; + if (m.startsWith("add") || m.startsWith("create") || m.startsWith("register")) return "CREATE"; + if (m.startsWith("generate")) return "GENERATE"; + if (m.startsWith("save") || m.startsWith("update") || m.startsWith("change")) return "UPDATE"; + return null; + } + + /** Best-effort id: from the returned entity, else the first Long argument. */ + private static String extractId(Object result, Object[] args) { + String fromResult = idOf(result); + if (fromResult != null) return fromResult; + if (args != null) { + for (Object a : args) { + if (a instanceof Long l) return String.valueOf(l); + } + for (Object a : args) { + String id = idOf(a); + if (id != null) return id; + } + } + return null; + } + + private static String idOf(Object obj) { + if (obj == null) return null; + try { + Method getId = obj.getClass().getMethod("getId"); + Object id = getId.invoke(obj); + return id != null ? String.valueOf(id) : null; + } catch (Exception ignored) { + return null; + } + } +} diff --git a/src/main/java/com/mvrent/carrental/config/AuthEventListener.java b/src/main/java/com/mvrent/carrental/config/AuthEventListener.java new file mode 100644 index 0000000..7e0f535 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/AuthEventListener.java @@ -0,0 +1,27 @@ +package com.mvrent.carrental.config; + +import com.mvrent.carrental.service.AuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.security.authentication.event.AbstractAuthenticationFailureEvent; +import org.springframework.security.authentication.event.AuthenticationSuccessEvent; +import org.springframework.stereotype.Component; + +/** Records login successes and failures in the audit trail. */ +@Component +@RequiredArgsConstructor +public class AuthEventListener { + + private final AuditService auditService; + + @EventListener + public void onSuccess(AuthenticationSuccessEvent event) { + auditService.logAuth("LOGIN_SUCCESS", event.getAuthentication().getName()); + } + + @EventListener + public void onFailure(AbstractAuthenticationFailureEvent event) { + Object principal = event.getAuthentication() != null ? event.getAuthentication().getPrincipal() : null; + auditService.logAuth("LOGIN_FAILURE", principal != null ? String.valueOf(principal) : "unknown"); + } +} diff --git a/src/main/java/com/mvrent/carrental/config/CurrencyAdvice.java b/src/main/java/com/mvrent/carrental/config/CurrencyAdvice.java new file mode 100644 index 0000000..3a7e6ec --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/CurrencyAdvice.java @@ -0,0 +1,32 @@ +package com.mvrent.carrental.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +/** + * Exposes the configurable currency symbol/code (from application.properties) to + * every server-rendered view as {@code currencySymbol} / {@code currencyCode}. + */ +@ControllerAdvice +public class CurrencyAdvice { + + private final String symbol; + private final String code; + + public CurrencyAdvice(@Value("${app.currency.symbol}") String symbol, + @Value("${app.currency.code}") String code) { + this.symbol = symbol; + this.code = code; + } + + @ModelAttribute("currencySymbol") + public String currencySymbol() { + return symbol; + } + + @ModelAttribute("currencyCode") + public String currencyCode() { + return code; + } +} diff --git a/src/main/java/com/mvrent/carrental/config/CurrentUserAdvice.java b/src/main/java/com/mvrent/carrental/config/CurrentUserAdvice.java new file mode 100644 index 0000000..5957e75 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/CurrentUserAdvice.java @@ -0,0 +1,50 @@ +package com.mvrent.carrental.config; + +import com.mvrent.carrental.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; + +import java.util.HashMap; +import java.util.Map; + +/** + * Exposes the logged-in user's display details to every view as {@code cu} + * (used by the topbar avatar / profile dropdown). + */ +@ControllerAdvice +@RequiredArgsConstructor +public class CurrentUserAdvice { + + private final UserRepository userRepository; + + @ModelAttribute("cu") + public Map currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { + return null; + } + return userRepository.findByUsername(auth.getName()).map(u -> { + Map m = new HashMap<>(); + String fullName = (u.getFullName() != null && !u.getFullName().isBlank()) + ? u.getFullName() : u.getUsername(); + m.put("username", u.getUsername()); + m.put("fullName", fullName); + m.put("email", u.getEmail()); + m.put("role", u.getRole().name()); + m.put("initials", initials(fullName)); + return m; + }).orElse(null); + } + + private static String initials(String name) { + String[] parts = name.trim().split("\\s+"); + if (parts.length >= 2 && !parts[0].isEmpty() && !parts[1].isEmpty()) { + return ("" + parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase(); + } + return name.substring(0, Math.min(2, name.length())).toUpperCase(); + } +} diff --git a/src/main/java/com/mvrent/carrental/config/DataInitializer.java b/src/main/java/com/mvrent/carrental/config/DataInitializer.java new file mode 100644 index 0000000..0366b7a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/DataInitializer.java @@ -0,0 +1,283 @@ +package com.mvrent.carrental.config; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.CarRepository; +import com.mvrent.carrental.repository.DriverRepository; +import com.mvrent.carrental.repository.MaintenanceRepository; +import com.mvrent.carrental.repository.ReviewRepository; +import com.mvrent.carrental.repository.UserRepository; +import com.mvrent.carrental.repository.VehicleLocationRepository; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.VendorService; + +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import java.util.EnumSet; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DataInitializer implements CommandLineRunner { + + private final UserRepository userRepository; + private final DriverRepository driverRepository; + private final CarRepository carRepository; + private final PasswordEncoder passwordEncoder; + private final ConfigService configService; + private final VendorService vendorService; + private final JdbcTemplate jdbcTemplate; + private final MaintenanceRepository maintenanceRepository; + private final ReviewRepository reviewRepository; + private final VehicleLocationRepository vehicleLocationRepository; + + @Override + public void run(String... args) { + // Fill new fleet columns on rows created before Phase 1 (null-safe, SQL level). + backfillCarDefaults(); + + // Organization + system config singletons (created on first access) + configService.getOrganization(); + configService.getSystemConfig(); + seedCategories(); + + seedAdmin(); + seed("demo", "demo@mvrent.local", "demo1234", "Demo Customer", Role.CUSTOMER); + seedDrivers(); + seedVendor(); + seedDemoCustomerCategory(); + seedCarFeatures(); + seedTelematics(); + seedMaintenance(); + seedReviews(); + } + + /** Demo GPS positions across Kerala so the Live Tracking map isn't empty. */ + private void seedTelematics() { + if (vehicleLocationRepository.count() > 0) return; + double[][] coords = { + {9.9312, 76.2673}, // Kochi + {8.5241, 76.9366}, // Trivandrum + {11.2588, 75.7804}, // Kozhikode + {10.5276, 76.2144}, // Thrissur + {9.5916, 76.5222} // Kottayam + }; + java.util.List cars = carRepository.findAll(); + int i = 0; + for (Car c : cars) { + if (i >= coords.length) break; + vehicleLocationRepository.save(VehicleLocation.builder() + .car(c).latitude(coords[i][0]).longitude(coords[i][1]) + .speedKph(i % 2 == 0 ? 0.0 : 42.0).heading(90.0 * i) + .odometer(38000 + i * 5200).ignitionOn(i % 2 != 0) + .updatedAt(java.time.LocalDateTime.now().minusMinutes(i)) + .build()); + i++; + } + if (i > 0) log.info("Seeded {} demo vehicle locations", i); + } + + private void seedMaintenance() { + if (maintenanceRepository.count() > 0) return; + carRepository.findAll().stream().findFirst().ifPresent(c -> + maintenanceRepository.save(MaintenanceRecord.builder() + .car(c).type(MaintenanceType.SERVICE).status(MaintenanceStatus.COMPLETED) + .serviceDate(java.time.LocalDate.now().minusMonths(2)).odometer(36500) + .cost(new BigDecimal("4800")).garage("AutoCare Kochi") + .notes("Engine oil, filter, brake pads") + .nextServiceDate(java.time.LocalDate.now().plusMonths(4)).nextServiceOdometer(46500) + .build())); + log.info("Seeded a demo maintenance record"); + } + + private void seedReviews() { + if (reviewRepository.count() > 0) return; + User demo = userRepository.findByUsername("demo").orElse(null); + if (demo == null) return; + carRepository.findAll().stream().findFirst().ifPresent(c -> { + reviewRepository.save(Review.builder().car(c).user(demo).rating(5) + .comment("Spotless car and a smooth booking experience. Highly recommend!") + .approved(true).build()); + reviewRepository.save(Review.builder().car(c).user(demo).rating(4) + .comment("Great value for money. Pickup was quick.") + .approved(true).build()); + }); + log.info("Seeded demo reviews"); + } + + private void seedCarFeatures() { + java.util.List cars = carRepository.findAll(); + boolean anyTags = cars.stream().anyMatch(c -> !c.getFeatureTags().isEmpty()); + if (anyTags) return; + int i = 0; + for (Car c : cars) { + java.util.Set tags = new java.util.LinkedHashSet<>(); + tags.add("AC"); + if (i % 2 == 0) { tags.add("GPS"); tags.add("Bluetooth"); } + if (c.getSeats() >= 7) tags.add("Spacious"); + if (c.getFuelType() == FuelType.ELECTRIC) tags.add("Fast Charging"); + c.setFeatureTags(tags); + carRepository.save(c); + i++; + } + log.info("Seeded feature tags on {} cars", cars.size()); + } + + private void seedDemoCustomerCategory() { + userRepository.findByUsername("demo").ifPresent(demo -> { + if (demo.getCustomerCategory() == null) { + configService.categoriesOfType(CategoryType.CUSTOMER).stream() + .filter(c -> c.getName().equals("Individual")).findFirst() + .ifPresent(cat -> { + demo.setCustomerCategory(cat); + userRepository.save(demo); + }); + } + }); + } + + private void seedVendor() { + // Idempotent: get-or-create the demo vendor, then ensure portal user + attachments. + Vendor vendor = vendorService.findAll().stream() + .filter(v -> v.getName().equals("Kerala Cabs")).findFirst() + .orElseGet(() -> vendorService.save(Vendor.builder() + .name("Kerala Cabs").contactPerson("Joby Thomas") + .phone("+91 9847000000").email("ops@keralacabs.local") + .gstNumber("32ABCDE1234F1Z5").settlementDetails("A/C 00112233, IFSC SBIN0001234") + .commissionPercent(new BigDecimal("12")).active(true).build())); + + if (!userRepository.existsByUsername("vendor1")) { + try { + vendorService.createPortalUser(vendor.getId(), "vendor1", "vendor1@mvrent.local", "vendor123"); + log.info("Seeded vendor 'Kerala Cabs' + portal login vendor1/vendor123"); + } catch (IllegalArgumentException ignored) { + // portal user already exists + } + } + + // Attach one car + one driver to the vendor for demo (only if none yet attached) + if (carRepository.countByVendorId(vendor.getId()) == 0) { + carRepository.findAll().stream().findFirst().ifPresent(c -> { + c.setOwnership(CarOwnership.VENDOR); + c.setVendor(vendor); + carRepository.save(c); + }); + } + driverRepository.findByVendorIdOrderByName(vendor.getId()).stream().findAny() + .ifPresentOrElse(d -> { }, () -> + driverRepository.findAll().stream() + .filter(d -> d.getName().contains("Suresh")).findFirst().ifPresent(d -> { + d.setEmploymentType(DriverEmploymentType.VENDOR); + d.setVendor(vendor); + driverRepository.save(d); + })); + } + + private void seedDrivers() { + if (driverRepository.count() > 0) return; + Category standard = configService.categoriesOfType(CategoryType.DRIVER).stream() + .filter(c -> c.getName().equals("Standard")).findFirst().orElse(null); + Category premium = configService.categoriesOfType(CategoryType.DRIVER).stream() + .filter(c -> c.getName().equals("Premium")).findFirst().orElse(null); + + driverRepository.save(Driver.builder() + .name("Rajesh Kumar").phone("+91 9847012345").licenceNumber("KL07-2018-0012345") + .licenceExpiry(java.time.LocalDate.now().plusYears(2)).yearsExperience(6) + .category(standard).employmentType(DriverEmploymentType.EMPLOYED) + .dailyCharge(new java.math.BigDecimal("800")).status(DriverStatus.AVAILABLE).build()); + + driverRepository.save(Driver.builder() + .name("Suresh Nair").phone("+91 9847098765").licenceNumber("KL01-2015-0098765") + .licenceExpiry(java.time.LocalDate.now().plusYears(1)).yearsExperience(11) + .category(premium).employmentType(DriverEmploymentType.EMPLOYED) + .dailyCharge(new java.math.BigDecimal("1200")).status(DriverStatus.AVAILABLE).build()); + + log.info("Seeded 2 demo drivers"); + } + + private void seedAdmin() { + User admin = userRepository.findByUsername("admin").orElse(null); + if (admin == null) { + admin = User.builder() + .username("admin") + .email("admin@mvrent.local") + .password(passwordEncoder.encode("admin123")) + .fullName("MV Rent Admin") + .role(Role.ADMIN) + .permissions(EnumSet.allOf(Permission.class)) + .enabled(true) + .build(); + userRepository.save(admin); + log.info("Seeded ADMIN user 'admin' (password: admin123) with all permissions"); + } else if (admin.getRole() == Role.ADMIN && admin.getPermissions().isEmpty()) { + // Backfill permissions for an admin created before the permission model existed. + admin.setPermissions(EnumSet.allOf(Permission.class)); + userRepository.save(admin); + log.info("Backfilled all permissions onto existing ADMIN user 'admin'"); + } + } + + private void seed(String username, String email, String rawPassword, String fullName, Role role) { + if (userRepository.existsByUsername(username)) return; + User user = User.builder() + .username(username) + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .fullName(fullName) + .role(role) + .enabled(true) + .build(); + userRepository.save(user); + log.info("Seeded {} user '{}' (password: {})", role, username, rawPassword); + } + + private void seedCategories() { + if (!configService.allCategories().isEmpty()) return; + // Vehicle categories + for (String n : new String[]{"Hatchback", "Sedan", "SUV", "MUV", "Luxury", "Bike"}) { + safeAdd(CategoryType.VEHICLE, n); + } + // Driver categories + for (String n : new String[]{"Standard", "Premium", "Chauffeur"}) { + safeAdd(CategoryType.DRIVER, n); + } + // Customer categories + for (String n : new String[]{"Individual", "Company", "Corporate"}) { + safeAdd(CategoryType.CUSTOMER, n); + } + log.info("Seeded default vehicle/driver/customer categories"); + } + + private void backfillCarDefaults() { + try { + // Drop the stale enum CHECK constraint Hibernate created when Role was + // only {ADMIN, CUSTOMER}; ddl-auto=update never widens it for STAFF/VENDOR. + jdbcTemplate.execute("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check"); + + jdbcTemplate.update("UPDATE cars SET status = 'ACTIVE' WHERE status IS NULL"); + jdbcTemplate.update("UPDATE cars SET ownership = 'OWNED' WHERE ownership IS NULL"); + jdbcTemplate.update("UPDATE cars SET units_total = 1 WHERE units_total IS NULL"); + + // Phase 4: fill new booking columns on pre-existing rows. + jdbcTemplate.update("UPDATE bookings SET with_driver = false WHERE with_driver IS NULL"); + jdbcTemplate.update("UPDATE bookings SET rental_unit = 'DAY' WHERE rental_unit IS NULL"); + jdbcTemplate.update("UPDATE bookings SET quantity = 1 WHERE quantity IS NULL"); + } catch (DataAccessException ex) { + log.warn("Schema backfill skipped: {}", ex.getMessage()); + } + } + + private void safeAdd(CategoryType type, String name) { + try { + configService.addCategory(type, name, null); + } catch (IllegalArgumentException ignored) { + // already present + } + } +} diff --git a/src/main/java/com/mvrent/carrental/config/PasswordConfig.java b/src/main/java/com/mvrent/carrental/config/PasswordConfig.java new file mode 100644 index 0000000..6e5db10 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/PasswordConfig.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * Standalone so the {@link PasswordEncoder} has no dependency on SecurityConfig — + * avoids a circular reference now that services (OAuth2LoginService) also need it. + */ +@Configuration +public class PasswordConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/mvrent/carrental/config/SecurityConfig.java b/src/main/java/com/mvrent/carrental/config/SecurityConfig.java new file mode 100644 index 0000000..f94ba0f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/SecurityConfig.java @@ -0,0 +1,178 @@ +package com.mvrent.carrental.config; + +import com.mvrent.carrental.service.CustomUserDetailsService; +import com.mvrent.carrental.service.OAuth2LoginService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomUserDetailsService userDetailsService; + private final OAuth2LoginService oAuth2LoginService; + private final PasswordEncoder passwordEncoder; + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; + } + + /** + * Telematics ingest chain — applies only to /api/telematics/**. + * Stateless and CSRF-exempt; authenticated by an API key in the controller, + * so GPS devices can POST pings directly without a session. + */ + @Bean + @Order(0) + public SecurityFilterChain telematicsSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/api/telematics/**") + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy( + org.springframework.security.config.http.SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); + return http.build(); + } + + /** + * Admin module chain — applies only to /admin/**. + * Has its own login page (/admin/login) and logout (/admin/logout), + * completely separate from the customer/citizen login flow below. + */ + @Bean + @Order(1) + public SecurityFilterChain adminSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/admin/**") + .authenticationProvider(authenticationProvider()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/admin/login").permitAll() + .anyRequest().hasAnyRole("ADMIN", "STAFF") + ) + .formLogin(form -> form + .loginPage("/admin/login") + .loginProcessingUrl("/admin/login") + .defaultSuccessUrl("/admin", true) + .failureUrl("/admin/login?error") + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/admin/logout") + .logoutSuccessUrl("/admin/login?logout") + .permitAll() + ); + + return http.build(); + } + + /** + * Vendor portal chain — applies only to /vendor/**. + * Own login (/vendor/login) and logout (/vendor/logout); ROLE_VENDOR only. + */ + @Bean + @Order(2) + public SecurityFilterChain vendorSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/vendor/**") + .authenticationProvider(authenticationProvider()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/vendor/login").permitAll() + .anyRequest().hasRole("VENDOR") + ) + .formLogin(form -> form + .loginPage("/vendor/login") + .loginProcessingUrl("/vendor/login") + .defaultSuccessUrl("/vendor", true) + .failureUrl("/vendor/login?error") + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/vendor/logout") + .logoutSuccessUrl("/vendor/login?logout") + .permitAll() + ); + + return http.build(); + } + + /** + * Driver portal chain — applies only to /driver/**. + * Own login (/driver/login) and logout (/driver/logout); ROLE_DRIVER only. + */ + @Bean + @Order(3) + public SecurityFilterChain driverSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/driver/**") + .authenticationProvider(authenticationProvider()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/driver/login").permitAll() + .anyRequest().hasRole("DRIVER") + ) + .formLogin(form -> form + .loginPage("/driver/login") + .loginProcessingUrl("/driver/login") + .defaultSuccessUrl("/driver", true) + .failureUrl("/driver/login?error") + .permitAll() + ) + .logout(logout -> logout + .logoutUrl("/driver/logout") + .logoutSuccessUrl("/driver/login?logout") + .permitAll() + ); + + return http.build(); + } + + /** + * Customer / citizen chain — handles everything outside /admin/**, /vendor/** and /driver/**. + * Public browsing + the standard /login and /register flow. + */ + @Bean + @Order(4) + public SecurityFilterChain customerSecurityFilterChain(HttpSecurity http) throws Exception { + http + .authenticationProvider(authenticationProvider()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/cars", "/cars/**", "/login", "/register", + "/oauth2/**", "/login/oauth2/**", + "/css/**", "/js/**", "/images/**", "/uploads/**", "/webjars/**", "/error") + .permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .loginPage("/login") + .defaultSuccessUrl("/", true) + .failureUrl("/login?error") + .permitAll() + ) + .oauth2Login(oauth -> oauth + .loginPage("/login") + .defaultSuccessUrl("/", true) + .failureUrl("/login?error") + .userInfoEndpoint(info -> info.userService(oAuth2LoginService)) + ) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/?logout") + .permitAll() + ); + + return http.build(); + } +} diff --git a/src/main/java/com/mvrent/carrental/config/WebConfig.java b/src/main/java/com/mvrent/carrental/config/WebConfig.java new file mode 100644 index 0000000..b9ac991 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/config/WebConfig.java @@ -0,0 +1,43 @@ +package com.mvrent.carrental.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.web.multipart.support.MultipartFilter; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.nio.file.Paths; + +/** Serves uploaded files from the configurable storage directory. */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${app.storage.location}") + private String location; + + @Value("${app.storage.url-prefix}") + private String urlPrefix; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String base = urlPrefix.endsWith("/") ? urlPrefix : urlPrefix + "/"; + String fileUri = Paths.get(location).toAbsolutePath().normalize().toUri().toString(); + registry.addResourceHandler(base + "**").addResourceLocations(fileUri); + } + + /** + * Parse multipart requests before Spring Security so the CSRF token in a + * multipart form body can be read (otherwise uploads fail with 403). + */ + @Bean + public FilterRegistrationBean multipartFilterRegistration() { + FilterRegistrationBean registration = + new FilterRegistrationBean<>(new MultipartFilter()); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + registration.addUrlPatterns("/*"); + return registration; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminAuditApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminAuditApiController.java new file mode 100644 index 0000000..7df89d8 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminAuditApiController.java @@ -0,0 +1,27 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.AuditLog; +import com.mvrent.carrental.service.AuditService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** JSON API backing the read-only audit log viewer. */ +@RestController +@RequestMapping("/admin/api/audit") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminAuditApiController { + + private final AuditService auditService; + + @GetMapping + public List list(@RequestParam(defaultValue = "500") int limit) { + return auditService.recent(Math.min(Math.max(limit, 1), 2000)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminAuditController.java b/src/main/java/com/mvrent/carrental/controller/AdminAuditController.java new file mode 100644 index 0000000..8f2f4f6 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminAuditController.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/audit") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminAuditController { + + @GetMapping + public String page() { + return "admin/audit"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminAuthController.java b/src/main/java/com/mvrent/carrental/controller/AdminAuthController.java new file mode 100644 index 0000000..f0f43b2 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminAuthController.java @@ -0,0 +1,18 @@ +package com.mvrent.carrental.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Dedicated entry point for the admin module — separate from the + * customer/citizen login handled by {@link AuthController}. + */ +@Controller +public class AdminAuthController { + + /** Admin-only login page, served at /admin/login (no public registration). */ + @GetMapping("/admin/login") + public String login() { + return "admin/login"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminBookingApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminBookingApiController.java new file mode 100644 index 0000000..4f91069 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminBookingApiController.java @@ -0,0 +1,122 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.BookingStatus; +import com.mvrent.carrental.model.RentalUnit; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.service.BookingService; +import com.mvrent.carrental.service.PaymentService; +import com.mvrent.carrental.service.UserService; +import com.mvrent.carrental.web.AdminBookingForm; +import com.mvrent.carrental.web.BookingDto; +import com.mvrent.carrental.web.PaymentDto; +import com.mvrent.carrental.web.PaymentForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX Manage Bookings screen (inline status + counter bookings). */ +@RestController +@RequestMapping("/admin/api/bookings") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BOOKINGS')") +public class AdminBookingApiController { + + private final BookingService bookingService; + private final UserService userService; + private final PaymentService paymentService; + + @GetMapping + public List list() { + Map paid = paymentService.paidByBooking(); + return bookingService.findAll().stream() + .map(b -> BookingDto.from(b, paid.get(b.getId()))) + .toList(); + } + + /* ---------- Payments ---------- */ + + @GetMapping("/{id}/payments") + public Map payments(@PathVariable Long id) { + Booking b = bookingService.findById(id); + BigDecimal total = b.getTotalAmount() != null ? b.getTotalAmount() : BigDecimal.ZERO; + BigDecimal paid = paymentService.totalPaid(id); + return Map.of( + "total", total, + "paid", paid, + "balance", total.subtract(paid), + "payments", paymentService.forBooking(id).stream().map(PaymentDto::from).toList() + ); + } + + @PostMapping("/{id}/payments") + public ResponseEntity addPayment(@PathVariable Long id, @RequestBody PaymentForm f) { + try { + paymentService.record(id, f.amount(), f.method(), f.reference(), f.paidAt(), f.notes()); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + } + + @PostMapping("/payments/{paymentId}/delete") + public ResponseEntity deletePayment(@PathVariable Long paymentId) { + paymentService.delete(paymentId); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/status") + public ResponseEntity updateStatus(@PathVariable Long id, @RequestParam BookingStatus status) { + bookingService.updateStatus(id, status); + return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", status.name())); + } + + /** Admin creates a booking on behalf of a customer (counter / phone booking). */ + @PostMapping + public ResponseEntity create(@RequestBody AdminBookingForm f) { + if (f.carId() == null) return bad("Please select a car"); + if (f.pickupDate() == null || f.returnDate() == null) return bad("Pickup and return dates are required"); + if (isBlank(f.pickupLocation())) return bad("Pickup location is required"); + if (isBlank(f.dropLocation())) return bad("Drop location is required"); + + // Resolve customer: an existing one, or create a walk-in record. + User customer; + try { + if (f.customerId() != null) { + customer = userService.findById(f.customerId()); + } else if (!isBlank(f.customerName())) { + customer = userService.createWalkInCustomer(f.customerName(), f.customerPhone(), f.customerEmail()); + } else { + return bad("Select a customer or enter a walk-in customer name"); + } + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + + Booking form = new Booking(); + form.setPickupDate(f.pickupDate()); + form.setReturnDate(f.returnDate()); + form.setPickupLocation(f.pickupLocation().trim()); + form.setDropLocation(f.dropLocation().trim()); + form.setRentalUnit(f.rentalUnit() != null ? f.rentalUnit() : RentalUnit.DAY); + form.setWithDriver(f.withDriver()); + + try { + Booking saved = bookingService.createBooking(customer, f.carId(), form, f.driverId()); + return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId())); + } catch (IllegalArgumentException | IllegalStateException ex) { + return bad(ex.getMessage()); + } + } + + private static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); } + + private static ResponseEntity> bad(String msg) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", msg == null ? "Could not create booking" : msg)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminCarApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminCarApiController.java new file mode 100644 index 0000000..7d4ec9e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminCarApiController.java @@ -0,0 +1,163 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.service.CarService; +import com.mvrent.carrental.service.FileStorageService; +import com.mvrent.carrental.web.CarDto; +import com.mvrent.carrental.web.CarForm; +import com.mvrent.carrental.web.CarMediaDto; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.*; +import java.util.stream.Collectors; + +/** JSON API backing the AJAX Manage Cars screen. */ +@RestController +@RequestMapping("/admin/api/cars") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminCarApiController { + + private final CarService carService; + private final FileStorageService fileStorage; + + @GetMapping + public List list() { + return carService.findAll().stream().map(CarDto::from).toList(); + } + + @GetMapping("/{id}") + public CarDto get(@PathVariable Long id) { + return CarDto.from(carService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody CarForm f) { + Map errors = validate(f); + if (!errors.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors)); + } + + Car car = new Car(); + if (f.id() != null) car.setId(f.id()); + car.setMake(trim(f.make())); + car.setModel(trim(f.model())); + car.setRegistrationNumber(trim(f.registrationNumber())); + car.setYearOfRegistration(f.yearOfRegistration()); + car.setColour(trim(f.colour())); + car.setType(f.type()); + car.setSeats(f.seats() == null ? 0 : f.seats()); + car.setTransmission(f.transmission()); + car.setFuelType(f.fuelType()); + car.setHourlyRate(f.hourlyRate()); + car.setPricePerDay(f.pricePerDay()); + car.setMonthlyRate(f.monthlyRate()); + car.setOwnership(f.ownership() == null ? CarOwnership.OWNED : f.ownership()); + car.setCommissionPercentOverride(f.commissionPercentOverride()); + car.setUnitsTotal(f.unitsTotal() == null ? 1 : f.unitsTotal()); + car.setStatus(f.status() == null ? CarStatus.ACTIVE : f.status()); + car.setImageUrl(trim(f.imageUrl())); + car.setAvailable(f.available() == null || f.available()); + + try { + Car saved = carService.saveFromForm(car, parseTags(f.featureTagsCsv()), f.vendorId()); + return ResponseEntity.ok(Map.of("ok", true, "car", CarDto.from(saved))); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "errors", Map.of("registrationNumber", "Registration number already exists"))); + } + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + try { + carService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "message", "Cannot delete — this car has bookings. Set it to RETIRED instead.")); + } + } + + /* ---------- Documents & photos ---------- */ + + @GetMapping("/{id}/media") + public CarMediaDto media(@PathVariable Long id) { + return CarMediaDto.from(carService.findById(id)); + } + + @PostMapping("/{id}/documents") + public ResponseEntity addDocument(@PathVariable Long id, + @RequestParam CarDocumentType docType, + @RequestParam(required = false) MultipartFile file, + @RequestParam(required = false) String fileUrl, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) { + String stored = fileStorage.store(file); + String url = stored != null ? stored : fileUrl; + if (url == null || url.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL")); + } + carService.addDocument(id, docType, url, issuedDate, expiryDate); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/documents/{docId}/delete") + public ResponseEntity deleteDocument(@PathVariable Long id, @PathVariable Long docId) { + carService.deleteDocument(docId); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/photos") + public ResponseEntity addPhoto(@PathVariable Long id, + @RequestParam(required = false) MultipartFile file, + @RequestParam(required = false) String url, + @RequestParam(defaultValue = "0") int sortOrder) { + String stored = fileStorage.store(file); + String finalUrl = stored != null ? stored : url; + if (finalUrl == null || finalUrl.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a photo or provide a URL")); + } + carService.addPhoto(id, finalUrl, sortOrder); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/photos/{photoId}/delete") + public ResponseEntity deletePhoto(@PathVariable Long id, @PathVariable Long photoId) { + carService.deletePhoto(photoId); + return ResponseEntity.ok(Map.of("ok", true)); + } + + private Map validate(CarForm f) { + Map e = new LinkedHashMap<>(); + if (isBlank(f.make())) e.put("make", "Make is required"); + if (isBlank(f.model())) e.put("model", "Model is required"); + if (isBlank(f.registrationNumber())) e.put("registrationNumber", "Registration number is required"); + if (f.type() == null) e.put("type", "Type is required"); + if (f.pricePerDay() == null || f.pricePerDay().compareTo(BigDecimal.ZERO) < 0) + e.put("pricePerDay", "Valid daily rate is required"); + if (f.seats() != null && (f.seats() < 2 || f.seats() > 15)) + e.put("seats", "Seats must be 2–15"); + return e; + } + + private Set parseTags(String csv) { + if (csv == null || csv.isBlank()) return new LinkedHashSet<>(); + return Arrays.stream(csv.split(",")) + .map(String::trim).filter(s -> !s.isEmpty()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + private static boolean isBlank(String s) { return s == null || s.isBlank(); } + private static String trim(String s) { return s == null ? null : s.trim(); } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminCategoryApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminCategoryApiController.java new file mode 100644 index 0000000..8663347 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminCategoryApiController.java @@ -0,0 +1,60 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.web.CategoryDto; +import com.mvrent.carrental.web.CategoryForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX category master screen. */ +@RestController +@RequestMapping("/admin/api/categories") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')") +public class AdminCategoryApiController { + + private final ConfigService configService; + + @GetMapping + public List list() { + return configService.allCategories().stream().map(CategoryDto::from).toList(); + } + + @PostMapping + public ResponseEntity add(@RequestBody CategoryForm f) { + if (f.type() == null) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Type is required")); + } + if (f.name() == null || f.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Name is required")); + } + try { + return ResponseEntity.ok(Map.of("ok", true, + "category", CategoryDto.from(configService.addCategory(f.type(), f.name().trim(), f.description())))); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage())); + } + } + + @PostMapping("/{id}/toggle") + public ResponseEntity toggle(@PathVariable Long id) { + configService.toggleCategory(id); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + try { + configService.deleteCategory(id); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (Exception ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "message", "Cannot delete — category is in use. Disable it instead.")); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminController.java b/src/main/java/com/mvrent/carrental/controller/AdminController.java new file mode 100644 index 0000000..8882ad5 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminController.java @@ -0,0 +1,214 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.BookingStatus; +import com.mvrent.carrental.model.Car; +import com.mvrent.carrental.model.CarDocumentType; +import com.mvrent.carrental.model.Invoice; +import com.mvrent.carrental.service.BookingService; +import com.mvrent.carrental.service.CarService; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.DashboardService; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.FileStorageService; +import com.mvrent.carrental.service.PaymentService; +import com.mvrent.carrental.service.UserService; +import com.mvrent.carrental.service.PdfService; +import com.mvrent.carrental.service.VendorService; +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Controller +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminController { + + private final CarService carService; + private final BookingService bookingService; + private final VendorService vendorService; + private final PdfService pdfService; + private final ConfigService configService; + private final FileStorageService fileStorage; + private final DashboardService dashboardService; + private final DriverService driverService; + private final UserService userService; + private final PaymentService paymentService; + + @GetMapping + public String dashboard(Model model) { + model.addAttribute("d", dashboardService.build()); + return "admin/dashboard"; + } + + /* ---------- Cars ---------- */ + + @GetMapping("/cars") + public String cars(Model model) { + model.addAttribute("cars", carService.findAll()); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/cars"; + } + + @GetMapping("/cars/new") + public String newCar(Model model) { + if (!model.containsAttribute("car")) { + model.addAttribute("car", new Car()); + } + model.addAttribute("vendors", vendorService.findActive()); + return "admin/car-form"; + } + + @PostMapping("/cars") + public String saveCar(@Valid @ModelAttribute("car") Car car, + BindingResult result, + @RequestParam(name = "featureTagsCsv", required = false) String featureTagsCsv, + @RequestParam(name = "vendorId", required = false) Long vendorId, + Model model, + RedirectAttributes ra) { + if (result.hasErrors()) { + model.addAttribute("vendors", vendorService.findActive()); + return "admin/car-form"; + } + carService.saveFromForm(car, parseTags(featureTagsCsv), vendorId); + ra.addFlashAttribute("success", "Car saved"); + return "redirect:/admin/cars"; + } + + @GetMapping("/cars/{id}/edit") + public String editCar(@PathVariable Long id, Model model) { + model.addAttribute("car", carService.findById(id)); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/car-form"; + } + + @PostMapping("/cars/{id}/delete") + public String deleteCar(@PathVariable Long id, RedirectAttributes ra) { + carService.delete(id); + ra.addFlashAttribute("success", "Car deleted"); + return "redirect:/admin/cars"; + } + + /* ---------- Car documents ---------- */ + + @PostMapping("/cars/{id}/documents") + public String addDocument(@PathVariable Long id, + @RequestParam CarDocumentType docType, + @RequestParam(required = false) String fileUrl, + @RequestParam(required = false) MultipartFile file, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate, + RedirectAttributes ra) { + String stored = fileStorage.store(file); + carService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate); + ra.addFlashAttribute("success", docType + " document added"); + return "redirect:/admin/cars/" + id + "/edit"; + } + + @PostMapping("/cars/{id}/documents/{docId}/delete") + public String deleteDocument(@PathVariable Long id, @PathVariable Long docId, + RedirectAttributes ra) { + carService.deleteDocument(docId); + ra.addFlashAttribute("success", "Document removed"); + return "redirect:/admin/cars/" + id + "/edit"; + } + + /* ---------- Car photos ---------- */ + + @PostMapping("/cars/{id}/photos") + public String addPhoto(@PathVariable Long id, + @RequestParam(required = false) String url, + @RequestParam(required = false) MultipartFile file, + @RequestParam(defaultValue = "0") int sortOrder, + RedirectAttributes ra) { + String stored = fileStorage.store(file); + String finalUrl = stored != null ? stored : url; + if (finalUrl == null || finalUrl.isBlank()) { + ra.addFlashAttribute("error", "Provide a photo file or URL"); + } else { + carService.addPhoto(id, finalUrl, sortOrder); + ra.addFlashAttribute("success", "Photo added"); + } + return "redirect:/admin/cars/" + id + "/edit"; + } + + @PostMapping("/cars/{id}/photos/{photoId}/delete") + public String deletePhoto(@PathVariable Long id, @PathVariable Long photoId, + RedirectAttributes ra) { + carService.deletePhoto(photoId); + ra.addFlashAttribute("success", "Photo removed"); + return "redirect:/admin/cars/" + id + "/edit"; + } + + /* ---------- Bookings ---------- */ + + @GetMapping("/bookings") + public String bookings(Model model) { + model.addAttribute("bookings", bookingService.findAll()); + model.addAttribute("customers", userService.findCustomers()); + model.addAttribute("availableCars", carService.findAvailable()); + model.addAttribute("availableDrivers", driverService.findAvailable()); + return "admin/bookings"; + } + + @PostMapping("/bookings/{id}/status") + public String updateBookingStatus(@PathVariable Long id, + @RequestParam BookingStatus status, + RedirectAttributes ra) { + bookingService.updateStatus(id, status); + ra.addFlashAttribute("success", "Booking #" + id + " set to " + status); + return "redirect:/admin/bookings"; + } + + @GetMapping("/bookings/{id}/invoice") + public String invoice(@PathVariable Long id, Model model) { + var booking = bookingService.findById(id); + java.math.BigDecimal paid = paymentService.totalPaid(id); + java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO; + model.addAttribute("booking", booking); + model.addAttribute("invoice", bookingService.invoiceFor(id)); + model.addAttribute("payments", paymentService.forBooking(id)); + model.addAttribute("amountPaid", paid); + model.addAttribute("balance", total.subtract(paid)); + model.addAttribute("pdfUrl", "/admin/bookings/" + id + "/invoice/pdf"); + return "bookings/invoice"; + } + + @GetMapping("/bookings/{id}/invoice/pdf") + public ResponseEntity invoicePdf(@PathVariable Long id) { + Invoice invoice = bookingService.invoiceFor(id); + byte[] pdf = pdfService.render("pdf/invoice", Map.of( + "booking", bookingService.findById(id), "invoice", invoice, + "orgName", configService.getOrganization().getName())); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"") + .body(pdf); + } + + /* ---------- helpers ---------- */ + + private Set parseTags(String csv) { + if (csv == null || csv.isBlank()) return new LinkedHashSet<>(); + return Arrays.stream(csv.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminDriverApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminDriverApiController.java new file mode 100644 index 0000000..0b38873 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminDriverApiController.java @@ -0,0 +1,125 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.FileStorageService; +import com.mvrent.carrental.web.DriverDto; +import com.mvrent.carrental.web.DriverForm; +import com.mvrent.carrental.web.DriverMediaDto; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX Manage Drivers screen. */ +@RestController +@RequestMapping("/admin/api/drivers") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')") +public class AdminDriverApiController { + + private final DriverService driverService; + private final FileStorageService fileStorage; + + @GetMapping + public List list() { + return driverService.findAll().stream().map(DriverDto::from).toList(); + } + + @GetMapping("/{id}") + public DriverDto get(@PathVariable Long id) { + return DriverDto.from(driverService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody DriverForm f) { + Map errors = new LinkedHashMap<>(); + if (isBlank(f.name())) errors.put("name", "Name is required"); + if (isBlank(f.licenceNumber())) errors.put("licenceNumber", "Licence number is required"); + if (!errors.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors)); + } + + Driver d = new Driver(); + if (f.id() != null) d.setId(f.id()); + d.setName(trim(f.name())); + d.setPhone(trim(f.phone())); + d.setLicenceNumber(trim(f.licenceNumber())); + d.setLicenceExpiry(f.licenceExpiry()); + d.setYearsExperience(f.yearsExperience() == null ? 0 : f.yearsExperience()); + d.setEmploymentType(f.employmentType() == null ? DriverEmploymentType.EMPLOYED : f.employmentType()); + d.setHourlyCharge(f.hourlyCharge()); + d.setDailyCharge(f.dailyCharge()); + d.setMonthlyCharge(f.monthlyCharge()); + d.setStatus(f.status() == null ? DriverStatus.AVAILABLE : f.status()); + d.setPhotoUrl(trim(f.photoUrl())); + + try { + Driver saved = driverService.saveFromForm(d, f.categoryId(), f.vendorId()); + return ResponseEntity.ok(Map.of("ok", true, "driver", DriverDto.from(saved))); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "errors", Map.of("licenceNumber", "Licence number already exists"))); + } + } + + /* ---------- Documents ---------- */ + + @GetMapping("/{id}/documents") + public DriverMediaDto documents(@PathVariable Long id) { + return DriverMediaDto.from(driverService.findById(id)); + } + + @PostMapping("/{id}/documents") + public ResponseEntity addDocument(@PathVariable Long id, + @RequestParam DriverDocumentType docType, + @RequestParam(required = false) MultipartFile file, + @RequestParam(required = false) String fileUrl, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate) { + String stored = fileStorage.store(file); + String url = stored != null ? stored : fileUrl; + if (url == null || url.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Upload a file or provide a URL")); + } + driverService.addDocument(id, docType, url, issuedDate, expiryDate); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/documents/{docId}/delete") + public ResponseEntity deleteDocument(@PathVariable Long id, @PathVariable Long docId) { + driverService.deleteDocument(docId); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + try { + driverService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "message", "Cannot delete — this driver has trips. Set status to OFF instead.")); + } + } + + /** Inline activate/deactivate: flips a driver between AVAILABLE and OFF. */ + @PostMapping("/{id}/status") + public ResponseEntity setStatus(@PathVariable Long id, @RequestParam DriverStatus status) { + Driver saved = driverService.updateStatus(id, status); + return ResponseEntity.ok(Map.of("ok", true, "id", id, "status", saved.getStatus().name())); + } + + private static boolean isBlank(String s) { return s == null || s.isBlank(); } + private static String trim(String s) { return s == null ? null : s.trim(); } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminDriverController.java b/src/main/java/com/mvrent/carrental/controller/AdminDriverController.java new file mode 100644 index 0000000..650c12d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminDriverController.java @@ -0,0 +1,144 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.CategoryType; +import com.mvrent.carrental.model.Driver; +import com.mvrent.carrental.model.DriverDocumentType; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.FileStorageService; +import com.mvrent.carrental.service.VendorService; +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; + +@Controller +@RequestMapping("/admin/drivers") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_DRIVERS')") +public class AdminDriverController { + + private final DriverService driverService; + private final ConfigService configService; + private final VendorService vendorService; + private final FileStorageService fileStorage; + + @GetMapping + public String list(Model model) { + model.addAttribute("drivers", driverService.findAll()); + model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER)); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/drivers"; + } + + @GetMapping("/new") + public String newDriver(Model model) { + if (!model.containsAttribute("driver")) { + model.addAttribute("driver", new Driver()); + } + model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER)); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/driver-form"; + } + + @PostMapping + public String save(@Valid @ModelAttribute("driver") Driver driver, + BindingResult result, + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Long vendorId, + Model model, + RedirectAttributes ra) { + if (result.hasErrors()) { + model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER)); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/driver-form"; + } + driverService.saveFromForm(driver, categoryId, vendorId); + ra.addFlashAttribute("success", "Driver saved"); + return "redirect:/admin/drivers"; + } + + @GetMapping("/{id}") + public String profile(@PathVariable Long id, Model model) { + Driver driver = driverService.findById(id); + model.addAttribute("driver", driver); + model.addAttribute("trips", driverService.tripHistory(driver)); + model.addAttribute("totalTrips", driverService.totalTrips(driver)); + model.addAttribute("initials", initials(driver.getName())); + return "admin/driver-profile"; + } + + private static String initials(String name) { + if (name == null || name.isBlank()) return "?"; + String[] p = name.trim().split("\\s+"); + if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) { + return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase(); + } + return name.substring(0, Math.min(2, name.length())).toUpperCase(); + } + + @GetMapping("/{id}/edit") + public String edit(@PathVariable Long id, Model model) { + model.addAttribute("driver", driverService.findById(id)); + model.addAttribute("categories", configService.categoriesOfType(CategoryType.DRIVER)); + model.addAttribute("vendors", vendorService.findActive()); + return "admin/driver-form"; + } + + @PostMapping("/{id}/delete") + public String delete(@PathVariable Long id, RedirectAttributes ra) { + driverService.delete(id); + ra.addFlashAttribute("success", "Driver deleted"); + return "redirect:/admin/drivers"; + } + + /** Create a /driver portal login for this driver. */ + @PostMapping("/{id}/portal-user") + public String createPortalUser(@PathVariable Long id, + @RequestParam String username, + @RequestParam String email, + @RequestParam String password, + RedirectAttributes ra) { + try { + driverService.createPortalUser(id, username, email, password); + ra.addFlashAttribute("success", "Driver portal login created for '" + username + "'"); + } catch (IllegalArgumentException ex) { + ra.addFlashAttribute("error", ex.getMessage()); + } + return "redirect:/admin/drivers/" + id; + } + + /* ---------- Documents ---------- */ + + @PostMapping("/{id}/documents") + public String addDocument(@PathVariable Long id, + @RequestParam DriverDocumentType docType, + @RequestParam(required = false) String fileUrl, + @RequestParam(required = false) MultipartFile file, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate issuedDate, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expiryDate, + RedirectAttributes ra) { + String stored = fileStorage.store(file); + driverService.addDocument(id, docType, stored != null ? stored : fileUrl, issuedDate, expiryDate); + ra.addFlashAttribute("success", docType + " document added"); + return "redirect:/admin/drivers/" + id + "/edit"; + } + + @PostMapping("/{id}/documents/{docId}/delete") + public String deleteDocument(@PathVariable Long id, @PathVariable Long docId, + RedirectAttributes ra) { + driverService.deleteDocument(docId); + ra.addFlashAttribute("success", "Document removed"); + return "redirect:/admin/drivers/" + id + "/edit"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminInspectionApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminInspectionApiController.java new file mode 100644 index 0000000..29033b6 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminInspectionApiController.java @@ -0,0 +1,66 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.InspectionCondition; +import com.mvrent.carrental.model.InspectionRecord; +import com.mvrent.carrental.service.InspectionService; +import com.mvrent.carrental.web.InspectionDto; +import com.mvrent.carrental.web.InspectionForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX Inspections screen. */ +@RestController +@RequestMapping("/admin/api/inspections") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminInspectionApiController { + + private final InspectionService inspectionService; + + @GetMapping + public List list(@RequestParam(required = false) Long carId) { + var records = carId != null ? inspectionService.findByCar(carId) : inspectionService.findAll(); + return records.stream().map(InspectionDto::from).toList(); + } + + @GetMapping("/{id}") + public InspectionDto get(@PathVariable Long id) { + return InspectionDto.from(inspectionService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody InspectionForm f) { + if (f.carId() == null) return bad("Please select a car"); + if (f.type() == null) return bad("Please select an inspection type"); + + InspectionRecord r = f.id() != null ? inspectionService.findById(f.id()) : new InspectionRecord(); + r.setType(f.type()); + r.setCondition(f.condition() != null ? f.condition() : InspectionCondition.GOOD); + r.setInspectionDate(f.inspectionDate()); + r.setInspector(trim(f.inspector())); + r.setOdometer(f.odometer()); + r.setFuelLevel(f.fuelLevel()); + r.setDamageNotes(trim(f.damageNotes())); + r.setPhotoUrl(trim(f.photoUrl())); + + InspectionRecord saved = inspectionService.save(r, f.carId(), f.bookingId()); + return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId())); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + inspectionService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } + + private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); } + + private static ResponseEntity> bad(String m) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminInspectionController.java b/src/main/java/com/mvrent/carrental/controller/AdminInspectionController.java new file mode 100644 index 0000000..3ac351a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminInspectionController.java @@ -0,0 +1,24 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.CarService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/inspections") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminInspectionController { + + private final CarService carService; + + @GetMapping + public String page(Model model) { + model.addAttribute("cars", carService.findAll()); + return "admin/inspections"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceApiController.java new file mode 100644 index 0000000..6191cfa --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceApiController.java @@ -0,0 +1,67 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.MaintenanceRecord; +import com.mvrent.carrental.model.MaintenanceStatus; +import com.mvrent.carrental.service.MaintenanceService; +import com.mvrent.carrental.web.MaintenanceDto; +import com.mvrent.carrental.web.MaintenanceForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX Maintenance screen. */ +@RestController +@RequestMapping("/admin/api/maintenance") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminMaintenanceApiController { + + private final MaintenanceService maintenanceService; + + @GetMapping + public List list(@RequestParam(required = false) Long carId) { + var records = carId != null ? maintenanceService.findByCar(carId) : maintenanceService.findAll(); + return records.stream().map(MaintenanceDto::from).toList(); + } + + @GetMapping("/{id}") + public MaintenanceDto get(@PathVariable Long id) { + return MaintenanceDto.from(maintenanceService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody MaintenanceForm f) { + if (f.carId() == null) return bad("Please select a car"); + if (f.type() == null) return bad("Please select a work type"); + + MaintenanceRecord r = f.id() != null ? maintenanceService.findById(f.id()) : new MaintenanceRecord(); + r.setType(f.type()); + r.setStatus(f.status() != null ? f.status() : MaintenanceStatus.COMPLETED); + r.setServiceDate(f.serviceDate()); + r.setOdometer(f.odometer()); + r.setCost(f.cost()); + r.setGarage(trim(f.garage())); + r.setNotes(trim(f.notes())); + r.setNextServiceDate(f.nextServiceDate()); + r.setNextServiceOdometer(f.nextServiceOdometer()); + + MaintenanceRecord saved = maintenanceService.save(r, f.carId()); + return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId())); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + maintenanceService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } + + private static String trim(String s) { return s == null || s.isBlank() ? null : s.trim(); } + + private static ResponseEntity> bad(String m) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceController.java b/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceController.java new file mode 100644 index 0000000..1a61fde --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminMaintenanceController.java @@ -0,0 +1,24 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.CarService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/maintenance") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminMaintenanceController { + + private final CarService carService; + + @GetMapping + public String page(Model model) { + model.addAttribute("cars", carService.findAll()); + return "admin/maintenance"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminProfileController.java b/src/main/java/com/mvrent/carrental/controller/AdminProfileController.java new file mode 100644 index 0000000..7825698 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminProfileController.java @@ -0,0 +1,39 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** Admin profile page + AJAX password change. */ +@Controller +@RequiredArgsConstructor +public class AdminProfileController { + + private final UserService userService; + + @GetMapping("/admin/profile") + public String profile(@AuthenticationPrincipal UserDetails principal, Model model) { + model.addAttribute("user", userService.findByUsername(principal.getUsername())); + return "admin/profile"; + } + + @PostMapping("/admin/api/profile/password") + @ResponseBody + public ResponseEntity changePassword(@AuthenticationPrincipal UserDetails principal, + @RequestBody Map body) { + try { + userService.changePassword(principal.getUsername(), + body.get("currentPassword"), body.get("newPassword")); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage())); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminReportController.java b/src/main/java/com/mvrent/carrental/controller/AdminReportController.java new file mode 100644 index 0000000..b43e129 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminReportController.java @@ -0,0 +1,120 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.service.*; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@Controller +@RequestMapping("/admin/reports") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('VIEW_REPORTS')") +public class AdminReportController { + + private final ReportService reportService; + private final CarService carService; + private final DriverService driverService; + private final VendorService vendorService; + private final ConfigService configService; + private final ExcelService excelService; + private final PdfService pdfService; + + /** Build a filter from raw query params, ignoring blanks (avoids empty-enum binding errors). */ + private BookingFilter parse(Map p) { + return new BookingFilter( + date(p.get("from")), date(p.get("to")), + lng(p.get("carId")), lng(p.get("driverId")), lng(p.get("vendorId")), + blank(p.get("ownership")) ? null : CarOwnership.valueOf(p.get("ownership")), + blank(p.get("status")) ? null : BookingStatus.valueOf(p.get("status")), + lng(p.get("customerCategoryId"))); + } + + private void addLookups(Model model) { + model.addAttribute("cars", carService.findAll()); + model.addAttribute("drivers", driverService.findAll()); + model.addAttribute("vendors", vendorService.findAll()); + model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER)); + model.addAttribute("ownerships", CarOwnership.values()); + model.addAttribute("statuses", BookingStatus.values()); + } + + @GetMapping + public String bookingsReport(@RequestParam Map params, Model model) { + BookingFilter f = parse(params); + List bookings = reportService.bookings(f); + model.addAttribute("bookings", bookings); + model.addAttribute("summary", reportService.summarize(bookings)); + model.addAttribute("p", params); // echo filters back into the form + export links + addLookups(model); + return "admin/reports"; + } + + @GetMapping("/excel") + public ResponseEntity bookingsExcel(@RequestParam Map params) { + List bookings = reportService.bookings(parse(params)); + byte[] xlsx = excelService.bookingsWorkbook(bookings, reportService.summarize(bookings)); + return download(xlsx, AdminSettlementController.XLSX, "attachment", "bookings-report.xlsx"); + } + + @GetMapping("/pdf") + public ResponseEntity bookingsPdf(@RequestParam Map params) { + List bookings = reportService.bookings(parse(params)); + byte[] pdf = pdfService.render("pdf/report-bookings", Map.of( + "bookings", bookings, + "summary", reportService.summarize(bookings), + "orgName", configService.getOrganization().getName())); + return download(pdf, MediaType.APPLICATION_PDF, "inline", "bookings-report.pdf"); + } + + /* ---------- Vendor-wise ---------- */ + + @GetMapping("/vendors") + public String vendorReport(Model model) { + model.addAttribute("rows", reportService.vendorReport()); + return "admin/report-vendors"; + } + + @GetMapping("/vendors/excel") + public ResponseEntity vendorReportExcel() { + return download(excelService.vendorReportWorkbook(reportService.vendorReport()), + AdminSettlementController.XLSX, "attachment", "vendor-report.xlsx"); + } + + /* ---------- Driver-wise ---------- */ + + @GetMapping("/drivers") + public String driverReport(Model model) { + model.addAttribute("rows", reportService.driverReport()); + return "admin/report-drivers"; + } + + @GetMapping("/drivers/excel") + public ResponseEntity driverReportExcel() { + return download(excelService.driverReportWorkbook(reportService.driverReport()), + AdminSettlementController.XLSX, "attachment", "driver-report.xlsx"); + } + + /* ---------- helpers ---------- */ + + private ResponseEntity download(byte[] body, MediaType type, String disposition, String filename) { + return ResponseEntity.ok().contentType(type) + .header(HttpHeaders.CONTENT_DISPOSITION, disposition + "; filename=\"" + filename + "\"") + .body(body); + } + + private static boolean blank(String s) { return s == null || s.isBlank(); } + private static Long lng(String s) { return blank(s) ? null : Long.valueOf(s.trim()); } + private static LocalDate date(String s) { return blank(s) ? null : LocalDate.parse(s.trim()); } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminReviewApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminReviewApiController.java new file mode 100644 index 0000000..38bae8e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminReviewApiController.java @@ -0,0 +1,38 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.ReviewService; +import com.mvrent.carrental.web.ReviewDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** JSON API backing the admin review moderation screen. */ +@RestController +@RequestMapping("/admin/api/reviews") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminReviewApiController { + + private final ReviewService reviewService; + + @GetMapping + public List list() { + return reviewService.findAll().stream().map(ReviewDto::from).toList(); + } + + @PostMapping("/{id}/approve") + public ResponseEntity approve(@PathVariable Long id, @RequestParam boolean approved) { + reviewService.setApproved(id, approved); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + reviewService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminReviewController.java b/src/main/java/com/mvrent/carrental/controller/AdminReviewController.java new file mode 100644 index 0000000..c63b5e1 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminReviewController.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/reviews") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminReviewController { + + @GetMapping + public String page() { + return "admin/reviews"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminSettingsApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminSettingsApiController.java new file mode 100644 index 0000000..4fec16e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminSettingsApiController.java @@ -0,0 +1,60 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Organization; +import com.mvrent.carrental.model.SystemConfig; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.web.ConfigForm; +import com.mvrent.carrental.web.OrgForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** JSON API backing the AJAX Settings forms. */ +@RestController +@RequestMapping("/admin/api/settings") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')") +public class AdminSettingsApiController { + + private final ConfigService configService; + + @PostMapping("/organization") + public ResponseEntity saveOrganization(@RequestBody OrgForm f) { + if (f.name() == null || f.name().isBlank()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "Organization name is required")); + } + Organization org = Organization.builder() + .name(f.name().trim()) + .legalName(f.legalName()) + .logoUrl(f.logoUrl()) + .address(f.address()) + .gstNumber(f.gstNumber()) + .contactEmail(f.contactEmail()) + .phone(f.phone()) + .currencyCode(blankToDefault(f.currencyCode(), "INR")) + .build(); + configService.saveOrganization(org); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @PostMapping("/config") + public ResponseEntity saveConfig(@RequestBody ConfigForm f) { + SystemConfig cfg = SystemConfig.builder() + .currencyCode(blankToDefault(f.currencyCode(), "INR")) + .defaultTaxPercent(f.defaultTaxPercent()) + .defaultCommissionPercent(f.defaultCommissionPercent()) + .defaultDriverDailyCharge(f.defaultDriverDailyCharge()) + .invoicePrefix(blankToDefault(f.invoicePrefix(), "INV")) + .settlementCycle(f.settlementCycle()) + .build(); + configService.saveSystemConfig(cfg); + return ResponseEntity.ok(Map.of("ok", true)); + } + + private static String blankToDefault(String s, String def) { + return (s == null || s.isBlank()) ? def : s.trim(); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminSettingsController.java b/src/main/java/com/mvrent/carrental/controller/AdminSettingsController.java new file mode 100644 index 0000000..c64c538 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminSettingsController.java @@ -0,0 +1,122 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.CategoryType; +import com.mvrent.carrental.model.Organization; +import com.mvrent.carrental.model.SystemConfig; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.NotificationService; +import com.mvrent.carrental.service.SmsService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.Map; + +@Controller +@RequestMapping("/admin/settings") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_CONFIG')") +public class AdminSettingsController { + + private final ConfigService configService; + private final NotificationService notificationService; + private final SmsService smsService; + + /* ---------- Organization + System config ---------- */ + + @GetMapping + public String settings(Model model) { + if (!model.containsAttribute("organization")) { + model.addAttribute("organization", configService.getOrganization()); + } + if (!model.containsAttribute("config")) { + model.addAttribute("config", configService.getSystemConfig()); + } + model.addAttribute("mailReady", notificationService.isReady()); + model.addAttribute("smsEnabled", smsService.isEnabled()); + model.addAttribute("adminEmail", notificationService.adminEmail()); + return "admin/settings"; + } + + @PostMapping("/test-email") + @ResponseBody + public ResponseEntity testEmail(@RequestParam String to) { + try { + notificationService.sendTest(to); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (RuntimeException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage())); + } + } + + @PostMapping("/organization") + public String saveOrganization(@Valid @ModelAttribute("organization") Organization organization, + BindingResult result, + Model model, + RedirectAttributes ra) { + if (result.hasErrors()) { + model.addAttribute("config", configService.getSystemConfig()); + return "admin/settings"; + } + configService.saveOrganization(organization); + ra.addFlashAttribute("success", "Organization profile saved"); + return "redirect:/admin/settings"; + } + + @PostMapping("/config") + public String saveConfig(@Valid @ModelAttribute("config") SystemConfig config, + BindingResult result, + Model model, + RedirectAttributes ra) { + if (result.hasErrors()) { + model.addAttribute("organization", configService.getOrganization()); + return "admin/settings"; + } + configService.saveSystemConfig(config); + ra.addFlashAttribute("success", "System configuration saved"); + return "redirect:/admin/settings"; + } + + /* ---------- Category masters ---------- */ + + @GetMapping("/categories") + public String categories(Model model) { + model.addAttribute("categories", configService.allCategories()); + model.addAttribute("types", CategoryType.values()); + return "admin/categories"; + } + + @PostMapping("/categories") + public String addCategory(@RequestParam CategoryType type, + @RequestParam String name, + @RequestParam(required = false) String description, + RedirectAttributes ra) { + try { + configService.addCategory(type, name.trim(), description); + ra.addFlashAttribute("success", type + " category '" + name + "' added"); + } catch (IllegalArgumentException ex) { + ra.addFlashAttribute("error", ex.getMessage()); + } + return "redirect:/admin/settings/categories"; + } + + @PostMapping("/categories/{id}/toggle") + public String toggleCategory(@PathVariable Long id, RedirectAttributes ra) { + configService.toggleCategory(id); + ra.addFlashAttribute("success", "Category updated"); + return "redirect:/admin/settings/categories"; + } + + @PostMapping("/categories/{id}/delete") + public String deleteCategory(@PathVariable Long id, RedirectAttributes ra) { + configService.deleteCategory(id); + ra.addFlashAttribute("success", "Category deleted"); + return "redirect:/admin/settings/categories"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminSettlementController.java b/src/main/java/com/mvrent/carrental/controller/AdminSettlementController.java new file mode 100644 index 0000000..79cb741 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminSettlementController.java @@ -0,0 +1,91 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.SettlementStatus; +import com.mvrent.carrental.model.VendorSettlement; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.ExcelService; +import com.mvrent.carrental.service.PdfService; +import com.mvrent.carrental.service.SettlementService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; +import java.util.Map; + +@Controller +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_BILLING')") +public class AdminSettlementController { + + static final MediaType XLSX = + MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + private final SettlementService settlementService; + private final PdfService pdfService; + private final ExcelService excelService; + private final ConfigService configService; + + @PostMapping("/admin/vendors/{vendorId}/settlements") + public String generate(@PathVariable Long vendorId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodStart, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate periodEnd, + RedirectAttributes ra) { + try { + VendorSettlement s = settlementService.generate(vendorId, periodStart, periodEnd); + ra.addFlashAttribute("success", "Settlement generated — net payable Rs. " + s.getNetPayable()); + return "redirect:/admin/settlements/" + s.getId(); + } catch (IllegalArgumentException ex) { + ra.addFlashAttribute("error", ex.getMessage()); + return "redirect:/admin/vendors/" + vendorId; + } + } + + @GetMapping("/admin/settlements") + public String list(Model model) { + model.addAttribute("settlements", settlementService.findAll()); + return "admin/settlements"; + } + + @GetMapping("/admin/settlements/{id}") + public String detail(@PathVariable Long id, Model model) { + VendorSettlement s = settlementService.findById(id); + model.addAttribute("settlement", s); + model.addAttribute("trips", settlementService.tripsFor(s)); + return "admin/settlement-detail"; + } + + @PostMapping("/admin/settlements/{id}/status") + public String status(@PathVariable Long id, @RequestParam SettlementStatus status, RedirectAttributes ra) { + settlementService.updateStatus(id, status); + ra.addFlashAttribute("success", "Settlement set to " + status); + return "redirect:/admin/settlements/" + id; + } + + @GetMapping("/admin/settlements/{id}/pdf") + public ResponseEntity pdf(@PathVariable Long id) { + VendorSettlement s = settlementService.findById(id); + byte[] pdf = pdfService.render("pdf/settlement", Map.of( + "settlement", s, "trips", settlementService.tripsFor(s), + "orgName", configService.getOrganization().getName())); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"") + .body(pdf); + } + + @GetMapping("/admin/settlements/{id}/excel") + public ResponseEntity excel(@PathVariable Long id) { + VendorSettlement s = settlementService.findById(id); + byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s)); + return ResponseEntity.ok().contentType(XLSX) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"") + .body(xlsx); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminTelematicsController.java b/src/main/java/com/mvrent/carrental/controller/AdminTelematicsController.java new file mode 100644 index 0000000..0e0f284 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminTelematicsController.java @@ -0,0 +1,33 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.TelematicsService; +import com.mvrent.carrental.web.VehicleLocationDto; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.List; + +@Controller +@RequestMapping("/admin/tracking") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_FLEET')") +public class AdminTelematicsController { + + private final TelematicsService telematicsService; + + @GetMapping + public String page(Model model) { + return "admin/tracking"; + } + + @GetMapping(value = "/data", produces = "application/json") + @ResponseBody + public List data() { + return telematicsService.findAll().stream().map(VehicleLocationDto::from).toList(); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminUploadApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminUploadApiController.java new file mode 100644 index 0000000..23cb8ea --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminUploadApiController.java @@ -0,0 +1,27 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.FileStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Map; + +/** Generic single-file upload — stores the file and returns its public URL. */ +@RestController +@RequestMapping("/admin/api/upload") +@RequiredArgsConstructor +public class AdminUploadApiController { + + private final FileStorageService fileStorage; + + @PostMapping + public ResponseEntity upload(@RequestParam MultipartFile file) { + String url = fileStorage.store(file); + if (url == null) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", "No file uploaded")); + } + return ResponseEntity.ok(Map.of("ok", true, "url", url)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminUserApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminUserApiController.java new file mode 100644 index 0000000..898ae1e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminUserApiController.java @@ -0,0 +1,76 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.UserService; +import com.mvrent.carrental.web.StaffDto; +import com.mvrent.carrental.web.StaffForm; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** JSON API backing the staff / user management screen. */ +@RestController +@RequestMapping("/admin/api/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')") +public class AdminUserApiController { + + private final UserService userService; + + @GetMapping + public List list() { + return userService.findStaff().stream().map(StaffDto::from).toList(); + } + + @GetMapping("/{id}") + public StaffDto get(@PathVariable Long id) { + return StaffDto.from(userService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody StaffForm f) { + try { + var saved = (f.id() != null) ? userService.updateStaff(f) : userService.createStaff(f); + return ResponseEntity.ok(Map.of("ok", true, "id", saved.getId())); + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + } + + @PostMapping("/{id}/enabled") + public ResponseEntity setEnabled(@PathVariable Long id, @RequestParam boolean enabled) { + try { + userService.setEnabled(id, enabled); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + } + + @PostMapping("/{id}/password") + public ResponseEntity resetPassword(@PathVariable Long id, @RequestParam String password) { + try { + userService.resetPassword(id, password); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + try { + userService.deleteStaff(id); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return bad(ex.getMessage()); + } + } + + private static ResponseEntity> bad(String m) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", m == null ? "Operation failed" : m)); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminUserController.java b/src/main/java/com/mvrent/carrental/controller/AdminUserController.java new file mode 100644 index 0000000..9630105 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminUserController.java @@ -0,0 +1,24 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Permission; +import com.mvrent.carrental.model.Role; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@Controller +@RequestMapping("/admin/users") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_USERS')") +public class AdminUserController { + + @GetMapping + public String page(Model model) { + model.addAttribute("permissions", Permission.values()); + model.addAttribute("roles", new Role[]{Role.STAFF, Role.ADMIN}); + return "admin/users"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminVendorApiController.java b/src/main/java/com/mvrent/carrental/controller/AdminVendorApiController.java new file mode 100644 index 0000000..b13d2ff --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminVendorApiController.java @@ -0,0 +1,73 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Vendor; +import com.mvrent.carrental.service.VendorService; +import com.mvrent.carrental.web.VendorDto; +import com.mvrent.carrental.web.VendorForm; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** JSON API backing the AJAX Manage Vendors screen. */ +@RestController +@RequestMapping("/admin/api/vendors") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')") +public class AdminVendorApiController { + + private final VendorService vendorService; + + @GetMapping + public List list() { + return vendorService.findAll().stream().map(VendorDto::from).toList(); + } + + @GetMapping("/{id}") + public VendorDto get(@PathVariable Long id) { + return VendorDto.from(vendorService.findById(id)); + } + + @PostMapping + public ResponseEntity save(@RequestBody VendorForm f) { + Map errors = new LinkedHashMap<>(); + if (isBlank(f.name())) errors.put("name", "Name is required"); + if (!errors.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "errors", errors)); + } + + // Load existing on edit so we don't null out anything outside the form. + Vendor v = f.id() != null ? vendorService.findById(f.id()) : new Vendor(); + v.setName(trim(f.name())); + v.setContactPerson(trim(f.contactPerson())); + v.setPhone(trim(f.phone())); + v.setEmail(trim(f.email())); + v.setAddress(trim(f.address())); + v.setGstNumber(trim(f.gstNumber())); + v.setSettlementDetails(trim(f.settlementDetails())); + v.setCommissionPercent(f.commissionPercent()); + v.setActive(f.active() == null || f.active()); + + Vendor saved = vendorService.save(v); + return ResponseEntity.ok(Map.of("ok", true, "vendor", VendorDto.from(saved))); + } + + @PostMapping("/{id}/delete") + public ResponseEntity delete(@PathVariable Long id) { + try { + vendorService.delete(id); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, + "message", "Cannot delete — vendor has cars, drivers or a portal login. Deactivate instead.")); + } + } + + private static boolean isBlank(String s) { return s == null || s.isBlank(); } + private static String trim(String s) { return s == null ? null : s.trim(); } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AdminVendorController.java b/src/main/java/com/mvrent/carrental/controller/AdminVendorController.java new file mode 100644 index 0000000..219b657 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AdminVendorController.java @@ -0,0 +1,98 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Vendor; +import com.mvrent.carrental.service.SettlementService; +import com.mvrent.carrental.service.VendorService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequestMapping("/admin/vendors") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN') or hasAuthority('MANAGE_VENDORS')") +public class AdminVendorController { + + private final VendorService vendorService; + private final SettlementService settlementService; + + @GetMapping + public String list(Model model) { + model.addAttribute("vendors", vendorService.findAll()); + return "admin/vendors"; + } + + @GetMapping("/new") + public String newVendor(Model model) { + if (!model.containsAttribute("vendor")) { + model.addAttribute("vendor", new Vendor()); + } + return "admin/vendor-form"; + } + + @PostMapping + public String save(@Valid @ModelAttribute("vendor") Vendor vendor, + BindingResult result, + RedirectAttributes ra) { + if (result.hasErrors()) { + return "admin/vendor-form"; + } + Vendor saved = vendorService.save(vendor); + ra.addFlashAttribute("success", "Vendor saved"); + return "redirect:/admin/vendors/" + saved.getId(); + } + + @GetMapping("/{id}") + public String detail(@PathVariable Long id, Model model) { + Vendor vendor = vendorService.findById(id); + model.addAttribute("vendor", vendor); + model.addAttribute("cars", vendorService.cars(id)); + model.addAttribute("drivers", vendorService.drivers(id)); + model.addAttribute("revenue", vendorService.revenue(id)); + model.addAttribute("settlements", settlementService.findByVendor(id)); + model.addAttribute("initials", initials(vendor.getName())); + return "admin/vendor-detail"; + } + + private static String initials(String name) { + if (name == null || name.isBlank()) return "?"; + String[] p = name.trim().split("\\s+"); + if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) { + return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase(); + } + return name.substring(0, Math.min(2, name.length())).toUpperCase(); + } + + @GetMapping("/{id}/edit") + public String edit(@PathVariable Long id, Model model) { + model.addAttribute("vendor", vendorService.findById(id)); + return "admin/vendor-form"; + } + + @PostMapping("/{id}/delete") + public String delete(@PathVariable Long id, RedirectAttributes ra) { + vendorService.delete(id); + ra.addFlashAttribute("success", "Vendor deleted"); + return "redirect:/admin/vendors"; + } + + @PostMapping("/{id}/portal-user") + public String createPortalUser(@PathVariable Long id, + @RequestParam String username, + @RequestParam String email, + @RequestParam String password, + RedirectAttributes ra) { + try { + vendorService.createPortalUser(id, username, email, password); + ra.addFlashAttribute("success", "Vendor portal login created for '" + username + "'"); + } catch (IllegalArgumentException ex) { + ra.addFlashAttribute("error", ex.getMessage()); + } + return "redirect:/admin/vendors/" + id; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/AuthController.java b/src/main/java/com/mvrent/carrental/controller/AuthController.java new file mode 100644 index 0000000..3690339 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/AuthController.java @@ -0,0 +1,60 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.CategoryType; +import com.mvrent.carrental.model.Role; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +@Controller +@RequiredArgsConstructor +public class AuthController { + + private final UserService userService; + private final ConfigService configService; + + @GetMapping("/login") + public String login() { + return "auth/login"; + } + + @GetMapping("/register") + public String registerForm(Model model) { + if (!model.containsAttribute("user")) { + model.addAttribute("user", new User()); + } + model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER)); + return "auth/register"; + } + + @PostMapping("/register") + public String register(@Valid @ModelAttribute("user") User user, + BindingResult result, + @RequestParam String rawPassword, + @RequestParam(required = false) Long customerCategoryId, + RedirectAttributes ra, + Model model) { + if (result.hasErrors()) { + model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER)); + return "auth/register"; + } + try { + user.setRole(Role.CUSTOMER); + user.setCustomerCategory(configService.findCategory(customerCategoryId)); + userService.register(user, rawPassword); + ra.addFlashAttribute("success", "Account created — please log in"); + return "redirect:/login"; + } catch (IllegalArgumentException ex) { + model.addAttribute("customerCategories", configService.categoriesOfType(CategoryType.CUSTOMER)); + model.addAttribute("error", ex.getMessage()); + return "auth/register"; + } + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/BookingController.java b/src/main/java/com/mvrent/carrental/controller/BookingController.java new file mode 100644 index 0000000..245c46d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/BookingController.java @@ -0,0 +1,132 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.Invoice; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.service.BookingService; +import com.mvrent.carrental.service.CarService; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.PaymentService; +import com.mvrent.carrental.service.PdfService; +import com.mvrent.carrental.service.UserService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.Map; + +@Controller +@RequiredArgsConstructor +public class BookingController { + + private final BookingService bookingService; + private final CarService carService; + private final DriverService driverService; + private final UserService userService; + private final PdfService pdfService; + private final ConfigService configService; + private final PaymentService paymentService; + + @GetMapping("/cars/{carId}/book") + public String newBooking(@PathVariable Long carId, + @RequestParam(required = false) Boolean withDriver, + @RequestParam(required = false) + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate pickup, + @RequestParam(required = false) + @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate ret, + Model model) { + model.addAttribute("car", carService.findById(carId)); + if (!model.containsAttribute("booking")) { + Booking booking = new Booking(); + if (withDriver != null) booking.setWithDriver(withDriver); + if (pickup != null) booking.setPickupDate(pickup); + if (ret != null) booking.setReturnDate(ret); + model.addAttribute("booking", booking); + } + model.addAttribute("drivers", driverService.findAvailable()); + return "bookings/form"; + } + + @PostMapping("/cars/{carId}/book") + public String createBooking(@PathVariable Long carId, + @Valid @ModelAttribute("booking") Booking booking, + BindingResult result, + @RequestParam(required = false) Long driverId, + @AuthenticationPrincipal UserDetails principal, + RedirectAttributes ra, + Model model) { + if (result.hasErrors()) { + model.addAttribute("car", carService.findById(carId)); + model.addAttribute("drivers", driverService.findAvailable()); + return "bookings/form"; + } + try { + User user = userService.findByUsername(principal.getUsername()); + Booking saved = bookingService.createBooking(user, carId, booking, driverId); + ra.addFlashAttribute("success", "Booking confirmed (id #" + saved.getId() + ")"); + return "redirect:/my-bookings"; + } catch (RuntimeException ex) { + model.addAttribute("car", carService.findById(carId)); + model.addAttribute("drivers", driverService.findAvailable()); + model.addAttribute("error", ex.getMessage()); + return "bookings/form"; + } + } + + @GetMapping("/my-bookings") + public String myBookings(@AuthenticationPrincipal UserDetails principal, Model model) { + User user = userService.findByUsername(principal.getUsername()); + model.addAttribute("bookings", bookingService.findByUser(user)); + return "bookings/my"; + } + + @GetMapping("/my-bookings/{id}/invoice") + public String invoice(@PathVariable Long id, + @AuthenticationPrincipal UserDetails principal, + Model model) { + User user = userService.findByUsername(principal.getUsername()); + Booking booking = bookingService.findById(id); + if (!booking.getUser().getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking"); + } + java.math.BigDecimal paid = paymentService.totalPaid(id); + java.math.BigDecimal total = booking.getTotalAmount() != null ? booking.getTotalAmount() : java.math.BigDecimal.ZERO; + model.addAttribute("booking", booking); + model.addAttribute("invoice", bookingService.invoiceFor(id)); + model.addAttribute("amountPaid", paid); + model.addAttribute("balance", total.subtract(paid)); + model.addAttribute("pdfUrl", "/my-bookings/" + id + "/invoice/pdf"); + return "bookings/invoice"; + } + + @GetMapping("/my-bookings/{id}/invoice/pdf") + public ResponseEntity invoicePdf(@PathVariable Long id, + @AuthenticationPrincipal UserDetails principal) { + User user = userService.findByUsername(principal.getUsername()); + Booking booking = bookingService.findById(id); + if (!booking.getUser().getId().equals(user.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your booking"); + } + Invoice invoice = bookingService.invoiceFor(id); + byte[] pdf = pdfService.render("pdf/invoice", Map.of( + "booking", booking, "invoice", invoice, + "orgName", configService.getOrganization().getName())); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + invoice.getInvoiceNumber() + ".pdf\"") + .body(pdf); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/CarController.java b/src/main/java/com/mvrent/carrental/controller/CarController.java new file mode 100644 index 0000000..770c5a4 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/CarController.java @@ -0,0 +1,99 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Car; +import com.mvrent.carrental.model.CarType; +import com.mvrent.carrental.model.Transmission; +import com.mvrent.carrental.service.CarService; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.ReviewService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Controller +@RequestMapping("/cars") +@RequiredArgsConstructor +public class CarController { + + private final CarService carService; + private final DriverService driverService; + private final ReviewService reviewService; + + @GetMapping + public String list(@RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret, + @RequestParam(required = false) CarType type, + @RequestParam(required = false) Integer minSeats, + @RequestParam(required = false) Transmission transmission, + @RequestParam(required = false) BigDecimal maxPrice, + @RequestParam(required = false) Boolean withDriver, + @RequestParam(required = false) String pickupLocation, + @RequestParam(required = false) String dropLocation, + @RequestParam(required = false) String promo, + Model model) { + + List cars; + Long days = null; + if (pickup != null && ret != null && ret.isAfter(pickup)) { + cars = carService.findAvailableBetween(pickup, ret); + days = Math.max(1, ChronoUnit.DAYS.between(pickup, ret)); + } else { + cars = carService.findAvailable(); + } + + // In-memory refine by the optional attribute filters. + cars = cars.stream() + .filter(c -> type == null || c.getType() == type) + .filter(c -> minSeats == null || c.getSeats() >= minSeats) + .filter(c -> transmission == null || c.getTransmission() == transmission) + .filter(c -> maxPrice == null || c.getPricePerDay().compareTo(maxPrice) <= 0) + .toList(); + + model.addAttribute("cars", cars); + model.addAttribute("days", days); + model.addAttribute("availableDrivers", driverService.findAvailable().size()); + // echo filters back into the form + model.addAttribute("pickup", pickup); + model.addAttribute("ret", ret); + model.addAttribute("type", type); + model.addAttribute("minSeats", minSeats); + model.addAttribute("transmission", transmission); + model.addAttribute("maxPrice", maxPrice); + model.addAttribute("withDriver", withDriver); + model.addAttribute("pickupLocation", pickupLocation); + model.addAttribute("dropLocation", dropLocation); + model.addAttribute("promo", promo); + model.addAttribute("carTypes", CarType.values()); + model.addAttribute("transmissions", Transmission.values()); + return "cars/list"; + } + + @GetMapping("/{id}") + public String detail(@PathVariable Long id, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickup, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate ret, + Model model) { + model.addAttribute("car", carService.findById(id)); + Long days = (pickup != null && ret != null && ret.isAfter(pickup)) + ? Math.max(1, ChronoUnit.DAYS.between(pickup, ret)) : null; + model.addAttribute("days", days); + model.addAttribute("pickup", pickup); + model.addAttribute("ret", ret); + model.addAttribute("availableDrivers", driverService.findAvailable().size()); + model.addAttribute("reviews", reviewService.approvedForCar(id)); + model.addAttribute("avgRating", reviewService.averageForCar(id)); + model.addAttribute("reviewCount", reviewService.countForCar(id)); + return "cars/detail"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/DriverAuthController.java b/src/main/java/com/mvrent/carrental/controller/DriverAuthController.java new file mode 100644 index 0000000..00a2d6a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/DriverAuthController.java @@ -0,0 +1,14 @@ +package com.mvrent.carrental.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** Dedicated login entry point for the driver portal. */ +@Controller +public class DriverAuthController { + + @GetMapping("/driver/login") + public String login() { + return "driver/login"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/DriverPortalController.java b/src/main/java/com/mvrent/carrental/controller/DriverPortalController.java new file mode 100644 index 0000000..110c7e0 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/DriverPortalController.java @@ -0,0 +1,110 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.BookingStatus; +import com.mvrent.carrental.model.Driver; +import com.mvrent.carrental.model.DriverStatus; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.service.DriverService; +import com.mvrent.carrental.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.time.LocalDate; +import java.util.List; + +/** Self-service portal scoped to the logged-in driver's own record and trips. */ +@Controller +@RequestMapping("/driver") +@RequiredArgsConstructor +public class DriverPortalController { + + private final UserService userService; + private final DriverService driverService; + + private Driver currentDriver(UserDetails principal) { + User user = userService.findByUsername(principal.getUsername()); + Driver driver = user.getDriver(); + if (driver == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No driver linked to this account"); + } + return driver; + } + + @GetMapping + public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) { + Driver driver = currentDriver(principal); + List trips = driverService.tripHistory(driver); + LocalDate today = LocalDate.now(); + List upcoming = trips.stream() + .filter(b -> b.getReturnDate() != null && !b.getReturnDate().isBefore(today) + && (b.getStatus() == BookingStatus.PENDING || b.getStatus() == BookingStatus.CONFIRMED)) + .toList(); + model.addAttribute("driver", driver); + model.addAttribute("initials", initials(driver.getName())); + model.addAttribute("totalTrips", driverService.totalTrips(driver)); + model.addAttribute("upcoming", upcoming); + model.addAttribute("recentTrips", trips.stream().limit(6).toList()); + return "driver/dashboard"; + } + + @GetMapping("/trips") + public String trips(@AuthenticationPrincipal UserDetails principal, Model model) { + Driver driver = currentDriver(principal); + model.addAttribute("driver", driver); + model.addAttribute("initials", initials(driver.getName())); + model.addAttribute("trips", driverService.tripHistory(driver)); + return "driver/trips"; + } + + @GetMapping("/profile") + public String profile(@AuthenticationPrincipal UserDetails principal, Model model) { + Driver driver = currentDriver(principal); + model.addAttribute("driver", driver); + model.addAttribute("initials", initials(driver.getName())); + return "driver/profile"; + } + + /** Driver toggles their own availability (AVAILABLE ↔ OFF). */ + @PostMapping("/availability") + public String availability(@AuthenticationPrincipal UserDetails principal, + @RequestParam DriverStatus status, + RedirectAttributes ra) { + Driver driver = currentDriver(principal); + // A driver may only mark themselves available or off (not ON_TRIP). + DriverStatus target = (status == DriverStatus.OFF) ? DriverStatus.OFF : DriverStatus.AVAILABLE; + driverService.updateStatus(driver.getId(), target); + ra.addFlashAttribute("success", "You are now " + (target == DriverStatus.OFF ? "off duty" : "available")); + return "redirect:/driver"; + } + + @PostMapping("/password") + public String changePassword(@AuthenticationPrincipal UserDetails principal, + @RequestParam String currentPassword, + @RequestParam String newPassword, + RedirectAttributes ra) { + try { + userService.changePassword(principal.getUsername(), currentPassword, newPassword); + ra.addFlashAttribute("success", "Password updated"); + } catch (IllegalArgumentException ex) { + ra.addFlashAttribute("error", ex.getMessage()); + } + return "redirect:/driver/profile"; + } + + private static String initials(String name) { + if (name == null || name.isBlank()) return "?"; + String[] p = name.trim().split("\\s+"); + if (p.length >= 2 && !p[0].isEmpty() && !p[1].isEmpty()) { + return ("" + p[0].charAt(0) + p[1].charAt(0)).toUpperCase(); + } + return name.substring(0, Math.min(2, name.length())).toUpperCase(); + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/HomeController.java b/src/main/java/com/mvrent/carrental/controller/HomeController.java new file mode 100644 index 0000000..cdad757 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/HomeController.java @@ -0,0 +1,31 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.Car; +import com.mvrent.carrental.service.CarService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +import java.math.BigDecimal; +import java.util.Comparator; +import java.util.List; + +@Controller +@RequiredArgsConstructor +public class HomeController { + + private final CarService carService; + + @GetMapping("/") + public String home(Model model) { + List available = carService.findAvailable(); + BigDecimal minRate = available.stream() + .map(Car::getPricePerDay) + .min(Comparator.naturalOrder()) + .orElse(BigDecimal.valueOf(800)); + model.addAttribute("featuredCars", available.stream().limit(8).toList()); + model.addAttribute("minRate", minRate); + return "index"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/ReviewController.java b/src/main/java/com/mvrent/carrental/controller/ReviewController.java new file mode 100644 index 0000000..d6c2b4c --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/ReviewController.java @@ -0,0 +1,38 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.service.ReviewService; +import com.mvrent.carrental.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +/** Customer-facing endpoint for leaving a car review. */ +@Controller +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewService reviewService; + private final UserService userService; + + @PostMapping("/reviews") + public String submit(@RequestParam Long carId, + @RequestParam(required = false) Long bookingId, + @RequestParam int rating, + @RequestParam(required = false) String comment, + @AuthenticationPrincipal UserDetails principal, + RedirectAttributes ra) { + try { + User user = userService.findByUsername(principal.getUsername()); + reviewService.addReview(user, carId, bookingId, rating, comment); + ra.addFlashAttribute("reviewSuccess", "Thanks! Your review has been posted."); + } catch (RuntimeException ex) { + ra.addFlashAttribute("reviewError", ex.getMessage()); + } + return "redirect:/cars/" + carId; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/TelematicsApiController.java b/src/main/java/com/mvrent/carrental/controller/TelematicsApiController.java new file mode 100644 index 0000000..18d7519 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/TelematicsApiController.java @@ -0,0 +1,36 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.service.TelematicsService; +import com.mvrent.carrental.web.TelematicsPing; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * Device-facing ingest endpoint for GPS pings. Authenticated by an API key + * header ({@code X-API-KEY}) rather than a session, so trackers can post directly. + */ +@RestController +@RequestMapping("/api/telematics") +@RequiredArgsConstructor +public class TelematicsApiController { + + private final TelematicsService telematicsService; + + @PostMapping("/ingest") + public ResponseEntity ingest(@RequestHeader(value = "X-API-KEY", required = false) String apiKey, + @RequestBody TelematicsPing ping) { + if (!telematicsService.apiKeyValid(apiKey)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("ok", false, "message", "Invalid API key")); + } + try { + telematicsService.ingest(ping); + return ResponseEntity.ok(Map.of("ok", true)); + } catch (IllegalArgumentException ex) { + return ResponseEntity.badRequest().body(Map.of("ok", false, "message", ex.getMessage())); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/VendorAuthController.java b/src/main/java/com/mvrent/carrental/controller/VendorAuthController.java new file mode 100644 index 0000000..1257d34 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/VendorAuthController.java @@ -0,0 +1,14 @@ +package com.mvrent.carrental.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +/** Dedicated login entry point for the vendor portal. */ +@Controller +public class VendorAuthController { + + @GetMapping("/vendor/login") + public String login() { + return "vendor/login"; + } +} diff --git a/src/main/java/com/mvrent/carrental/controller/VendorPortalController.java b/src/main/java/com/mvrent/carrental/controller/VendorPortalController.java new file mode 100644 index 0000000..3fc5368 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/controller/VendorPortalController.java @@ -0,0 +1,124 @@ +package com.mvrent.carrental.controller; + +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.model.Vendor; +import com.mvrent.carrental.model.VendorSettlement; +import com.mvrent.carrental.service.ConfigService; +import com.mvrent.carrental.service.ExcelService; +import com.mvrent.carrental.service.PdfService; +import com.mvrent.carrental.service.SettlementService; +import com.mvrent.carrental.service.UserService; +import com.mvrent.carrental.service.VendorService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.Map; + +/** Self-service portal scoped to the logged-in vendor's own data. */ +@Controller +@RequestMapping("/vendor") +@RequiredArgsConstructor +public class VendorPortalController { + + private final UserService userService; + private final VendorService vendorService; + private final SettlementService settlementService; + private final PdfService pdfService; + private final ExcelService excelService; + private final ConfigService configService; + + private Vendor currentVendor(UserDetails principal) { + User user = userService.findByUsername(principal.getUsername()); + Vendor vendor = user.getVendor(); + if (vendor == null) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "No vendor linked to this account"); + } + return vendor; + } + + @GetMapping + public String dashboard(@AuthenticationPrincipal UserDetails principal, Model model) { + Vendor vendor = currentVendor(principal); + model.addAttribute("vendor", vendor); + model.addAttribute("carCount", vendorService.cars(vendor.getId()).size()); + model.addAttribute("driverCount", vendorService.drivers(vendor.getId()).size()); + model.addAttribute("revenue", vendorService.revenue(vendor.getId())); + model.addAttribute("recentTrips", vendorService.trips(vendor.getId()).stream().limit(5).toList()); + return "vendor/dashboard"; + } + + @GetMapping("/cars") + public String cars(@AuthenticationPrincipal UserDetails principal, Model model) { + Vendor vendor = currentVendor(principal); + model.addAttribute("vendor", vendor); + model.addAttribute("cars", vendorService.cars(vendor.getId())); + return "vendor/cars"; + } + + @GetMapping("/drivers") + public String drivers(@AuthenticationPrincipal UserDetails principal, Model model) { + Vendor vendor = currentVendor(principal); + model.addAttribute("vendor", vendor); + model.addAttribute("drivers", vendorService.drivers(vendor.getId())); + return "vendor/drivers"; + } + + @GetMapping("/trips") + public String trips(@AuthenticationPrincipal UserDetails principal, Model model) { + Vendor vendor = currentVendor(principal); + model.addAttribute("vendor", vendor); + model.addAttribute("trips", vendorService.trips(vendor.getId())); + model.addAttribute("revenue", vendorService.revenue(vendor.getId())); + return "vendor/trips"; + } + + @GetMapping("/settlements") + public String settlements(@AuthenticationPrincipal UserDetails principal, Model model) { + Vendor vendor = currentVendor(principal); + model.addAttribute("vendor", vendor); + model.addAttribute("settlements", settlementService.findByVendor(vendor.getId())); + return "vendor/settlements"; + } + + private VendorSettlement ownSettlement(UserDetails principal, Long id) { + Vendor vendor = currentVendor(principal); + VendorSettlement s = settlementService.findById(id); + if (!s.getVendor().getId().equals(vendor.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your settlement"); + } + return s; + } + + @GetMapping("/settlements/{id}/pdf") + public ResponseEntity settlementPdf(@AuthenticationPrincipal UserDetails principal, + @PathVariable Long id) { + VendorSettlement s = ownSettlement(principal, id); + byte[] pdf = pdfService.render("pdf/settlement", Map.of( + "settlement", s, "trips", settlementService.tripsFor(s), + "orgName", configService.getOrganization().getName())); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"settlement-" + s.getId() + ".pdf\"") + .body(pdf); + } + + @GetMapping("/settlements/{id}/excel") + public ResponseEntity settlementExcel(@AuthenticationPrincipal UserDetails principal, + @PathVariable Long id) { + VendorSettlement s = ownSettlement(principal, id); + byte[] xlsx = excelService.settlementWorkbook(s, settlementService.tripsFor(s)); + return ResponseEntity.ok().contentType(AdminSettlementController.XLSX) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"settlement-" + s.getId() + ".xlsx\"") + .body(xlsx); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/AuditLog.java b/src/main/java/com/mvrent/carrental/model/AuditLog.java new file mode 100644 index 0000000..7c94141 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/AuditLog.java @@ -0,0 +1,45 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** An immutable record of a significant action — who did what, when. */ +@Entity +@Table(name = "audit_logs", indexes = { + @Index(name = "idx_audit_created", columnList = "createdAt"), + @Index(name = "idx_audit_username", columnList = "username") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + private String username; + + private String role; + + /** CREATE, UPDATE, DELETE, GENERATE, LOGIN_SUCCESS, LOGIN_FAILURE, … */ + private String action; + + /** The kind of thing acted on (Car, Driver, Booking, …). */ + private String entityType; + + private String entityId; + + @Column(length = 500) + private String detail; + + private String ip; +} diff --git a/src/main/java/com/mvrent/carrental/model/Booking.java b/src/main/java/com/mvrent/carrental/model/Booking.java new file mode 100644 index 0000000..abbfb53 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Booking.java @@ -0,0 +1,98 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "bookings") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Booking { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + /** Assigned driver for with-driver bookings. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "driver_id") + private Driver driver; + + @ColumnDefault("false") + @Builder.Default + private boolean withDriver = false; + + @NotNull + private LocalDate pickupDate; + + @NotNull + private LocalDate returnDate; + + @NotBlank + private String pickupLocation; + + @NotBlank + private String dropLocation; + + /* ---------- Period & charge breakdown ---------- */ + + @Enumerated(EnumType.STRING) + @ColumnDefault("'DAY'") + @Builder.Default + private RentalUnit rentalUnit = RentalUnit.DAY; + + /** Number of billed periods (days or months). */ + @ColumnDefault("1") + @Builder.Default + private int quantity = 1; + + private BigDecimal vehicleCharge; + private BigDecimal driverCharge; + private BigDecimal taxPercent; + private BigDecimal taxAmount; + + @Column(nullable = false) + private BigDecimal totalAmount; + + /* ---------- Revenue attribution ---------- */ + + @Enumerated(EnumType.STRING) + private CarOwnership revenueOwner; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "vendor_id") + private Vendor vendor; + + private BigDecimal commissionPercent; + private BigDecimal commissionAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BookingStatus status; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) createdAt = LocalDateTime.now(); + if (status == null) status = BookingStatus.PENDING; + } +} diff --git a/src/main/java/com/mvrent/carrental/model/BookingStatus.java b/src/main/java/com/mvrent/carrental/model/BookingStatus.java new file mode 100644 index 0000000..6e8b230 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/BookingStatus.java @@ -0,0 +1,5 @@ +package com.mvrent.carrental.model; + +public enum BookingStatus { + PENDING, CONFIRMED, CANCELLED, COMPLETED +} diff --git a/src/main/java/com/mvrent/carrental/model/Car.java b/src/main/java/com/mvrent/carrental/model/Car.java new file mode 100644 index 0000000..38156ed --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Car.java @@ -0,0 +1,119 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "cars") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Car { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String make; + + @NotBlank + private String model; + + @Column(unique = true, nullable = false) + @NotBlank + private String registrationNumber; + + /** Year the vehicle was registered, e.g. 2022. */ + private Integer yearOfRegistration; + + private String colour; + + @Enumerated(EnumType.STRING) + @NotNull + private CarType type; + + @Min(2) + @Max(15) + private int seats; + + @Enumerated(EnumType.STRING) + private Transmission transmission; + + @Enumerated(EnumType.STRING) + private FuelType fuelType; + + /** Feature tags such as AC, GPS, Sunroof, Bluetooth. */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "car_feature_tags", joinColumns = @JoinColumn(name = "car_id")) + @Column(name = "tag") + @Builder.Default + private Set featureTags = new HashSet<>(); + + /* ---------- Rates ---------- */ + + private BigDecimal hourlyRate; + + @NotNull + @DecimalMin("0.0") + private BigDecimal pricePerDay; // daily rate + + private BigDecimal monthlyRate; + + /* ---------- Ownership & availability ---------- */ + + @Enumerated(EnumType.STRING) + @ColumnDefault("'OWNED'") + @Builder.Default + private CarOwnership ownership = CarOwnership.OWNED; + + /** Set when ownership = VENDOR. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "vendor_id") + private Vendor vendor; + + /** Per-vehicle commission override; falls back to vendor/global when null. */ + private BigDecimal commissionPercentOverride; + + @ColumnDefault("1") + @Builder.Default + private int unitsTotal = 1; + + @Enumerated(EnumType.STRING) + @ColumnDefault("'ACTIVE'") + @Builder.Default + private CarStatus status = CarStatus.ACTIVE; + + private String imageUrl; + + @Builder.Default + private boolean available = true; + + /* ---------- Documents & photos ---------- */ + + @OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("expiryDate ASC") + @Builder.Default + private List documents = new ArrayList<>(); + + @OneToMany(mappedBy = "car", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("sortOrder ASC") + @Builder.Default + private List photos = new ArrayList<>(); + + /** Bookable only when listed and operationally active. */ + @Transient + public boolean isBookable() { + return available && status == CarStatus.ACTIVE; + } +} diff --git a/src/main/java/com/mvrent/carrental/model/CarDocument.java b/src/main/java/com/mvrent/carrental/model/CarDocument.java new file mode 100644 index 0000000..ad89f11 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarDocument.java @@ -0,0 +1,52 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +import java.time.LocalDate; + +/** + * A compliance document (RC, insurance, permit, …) for a car, with an expiry + * date so the fleet can flag documents that are expired or expiring soon. + */ +@Entity +@Table(name = "car_documents") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CarDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + @Enumerated(EnumType.STRING) + @NotNull + private CarDocumentType docType; + + /** Path or URL to the stored file (local filesystem path for v1). */ + private String fileUrl; + + private LocalDate issuedDate; + + private LocalDate expiryDate; + + @Transient + public boolean isExpired() { + return expiryDate != null && expiryDate.isBefore(LocalDate.now()); + } + + @Transient + public boolean isExpiringSoon() { + return expiryDate != null + && !expiryDate.isBefore(LocalDate.now()) + && expiryDate.isBefore(LocalDate.now().plusDays(30)); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/CarDocumentType.java b/src/main/java/com/mvrent/carrental/model/CarDocumentType.java new file mode 100644 index 0000000..20518bf --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarDocumentType.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental.model; + +/** Type of compliance document attached to a car. */ +public enum CarDocumentType { + RC, + INSURANCE, + PERMIT, + POLLUTION, + FITNESS, + OTHER +} diff --git a/src/main/java/com/mvrent/carrental/model/CarOwnership.java b/src/main/java/com/mvrent/carrental/model/CarOwnership.java new file mode 100644 index 0000000..491b0c7 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarOwnership.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.model; + +/** Whether a car belongs to the organization or is attached by a vendor. */ +public enum CarOwnership { + OWNED, + VENDOR +} diff --git a/src/main/java/com/mvrent/carrental/model/CarPhoto.java b/src/main/java/com/mvrent/carrental/model/CarPhoto.java new file mode 100644 index 0000000..c4df478 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarPhoto.java @@ -0,0 +1,29 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; + +/** A gallery photo for a car (URL/path based for v1). */ +@Entity +@Table(name = "car_photos") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CarPhoto { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + @Column(nullable = false) + private String url; + + @Builder.Default + private int sortOrder = 0; +} diff --git a/src/main/java/com/mvrent/carrental/model/CarStatus.java b/src/main/java/com/mvrent/carrental/model/CarStatus.java new file mode 100644 index 0000000..c488dbc --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarStatus.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Operational state of a car. Only ACTIVE cars can be booked. */ +public enum CarStatus { + ACTIVE, + MAINTENANCE, + RETIRED +} diff --git a/src/main/java/com/mvrent/carrental/model/CarType.java b/src/main/java/com/mvrent/carrental/model/CarType.java new file mode 100644 index 0000000..7583c93 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CarType.java @@ -0,0 +1,5 @@ +package com.mvrent.carrental.model; + +public enum CarType { + HATCHBACK, SEDAN, SUV, MUV, LUXURY, BIKE +} diff --git a/src/main/java/com/mvrent/carrental/model/Category.java b/src/main/java/com/mvrent/carrental/model/Category.java new file mode 100644 index 0000000..7e36bcd --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Category.java @@ -0,0 +1,39 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +/** + * Admin-configurable master category for vehicles, drivers or customers. + * Replaces hard-coded enums going forward; consumed by the Fleet/Driver/Customer + * modules in their respective phases. + */ +@Entity +@Table(name = "categories", + uniqueConstraints = @UniqueConstraint(columnNames = {"type", "name"})) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Category { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @NotNull + @Column(nullable = false) + private CategoryType type; + + @NotBlank + @Column(nullable = false) + private String name; + + private String description; + + @Builder.Default + private boolean active = true; +} diff --git a/src/main/java/com/mvrent/carrental/model/CategoryType.java b/src/main/java/com/mvrent/carrental/model/CategoryType.java new file mode 100644 index 0000000..ff9c4e5 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/CategoryType.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Which domain a {@link Category} master record applies to. */ +public enum CategoryType { + VEHICLE, + DRIVER, + CUSTOMER +} diff --git a/src/main/java/com/mvrent/carrental/model/Driver.java b/src/main/java/com/mvrent/carrental/model/Driver.java new file mode 100644 index 0000000..1ed69aa --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Driver.java @@ -0,0 +1,86 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "drivers") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Driver { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String name; + + private String phone; + + @Column(unique = true, nullable = false) + @NotBlank + private String licenceNumber; + + private LocalDate licenceExpiry; + + @Min(0) + @ColumnDefault("0") + @Builder.Default + private int yearsExperience = 0; + + /** Driver category (master record of type DRIVER). */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "category_id") + private Category category; + + @Enumerated(EnumType.STRING) + @ColumnDefault("'EMPLOYED'") + @Builder.Default + private DriverEmploymentType employmentType = DriverEmploymentType.EMPLOYED; + + /** Set when employmentType = VENDOR. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "vendor_id") + private Vendor vendor; + + /* ---------- Charges ---------- */ + + private BigDecimal hourlyCharge; + private BigDecimal dailyCharge; + private BigDecimal monthlyCharge; + + /* ---------- Availability ---------- */ + + @Enumerated(EnumType.STRING) + @ColumnDefault("'AVAILABLE'") + @Builder.Default + private DriverStatus status = DriverStatus.AVAILABLE; + + private String photoUrl; + + @OneToMany(mappedBy = "driver", cascade = CascadeType.ALL, orphanRemoval = true) + @OrderBy("expiryDate ASC") + @Builder.Default + private List documents = new ArrayList<>(); + + @Transient + public boolean isLicenceExpired() { + return licenceExpiry != null && licenceExpiry.isBefore(LocalDate.now()); + } + + @Transient + public boolean isAssignable() { + return status == DriverStatus.AVAILABLE && !isLicenceExpired(); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/DriverDocument.java b/src/main/java/com/mvrent/carrental/model/DriverDocument.java new file mode 100644 index 0000000..05399f8 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/DriverDocument.java @@ -0,0 +1,48 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +import java.time.LocalDate; + +/** A document for a driver (licence, ID proof, …) with expiry tracking. */ +@Entity +@Table(name = "driver_documents") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DriverDocument { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "driver_id") + private Driver driver; + + @Enumerated(EnumType.STRING) + @NotNull + private DriverDocumentType docType; + + private String fileUrl; + + private LocalDate issuedDate; + + private LocalDate expiryDate; + + @Transient + public boolean isExpired() { + return expiryDate != null && expiryDate.isBefore(LocalDate.now()); + } + + @Transient + public boolean isExpiringSoon() { + return expiryDate != null + && !expiryDate.isBefore(LocalDate.now()) + && expiryDate.isBefore(LocalDate.now().plusDays(30)); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/DriverDocumentType.java b/src/main/java/com/mvrent/carrental/model/DriverDocumentType.java new file mode 100644 index 0000000..3245446 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/DriverDocumentType.java @@ -0,0 +1,10 @@ +package com.mvrent.carrental.model; + +/** Type of document attached to a driver. */ +public enum DriverDocumentType { + LICENCE, + ID_PROOF, + POLICE_VERIFICATION, + MEDICAL, + OTHER +} diff --git a/src/main/java/com/mvrent/carrental/model/DriverEmploymentType.java b/src/main/java/com/mvrent/carrental/model/DriverEmploymentType.java new file mode 100644 index 0000000..8bfa42a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/DriverEmploymentType.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.model; + +/** Whether a driver is employed by the organization or supplied by a vendor. */ +public enum DriverEmploymentType { + EMPLOYED, + VENDOR +} diff --git a/src/main/java/com/mvrent/carrental/model/DriverStatus.java b/src/main/java/com/mvrent/carrental/model/DriverStatus.java new file mode 100644 index 0000000..1376965 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/DriverStatus.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Availability state of a driver. Only AVAILABLE drivers can be assigned. */ +public enum DriverStatus { + AVAILABLE, + ON_TRIP, + OFF +} diff --git a/src/main/java/com/mvrent/carrental/model/FuelType.java b/src/main/java/com/mvrent/carrental/model/FuelType.java new file mode 100644 index 0000000..8c17d2d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/FuelType.java @@ -0,0 +1,5 @@ +package com.mvrent.carrental.model; + +public enum FuelType { + PETROL, DIESEL, ELECTRIC, HYBRID, CNG +} diff --git a/src/main/java/com/mvrent/carrental/model/InspectionCondition.java b/src/main/java/com/mvrent/carrental/model/InspectionCondition.java new file mode 100644 index 0000000..f776cb1 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/InspectionCondition.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Overall condition recorded at an inspection. */ +public enum InspectionCondition { + GOOD, + MINOR_DAMAGE, + MAJOR_DAMAGE +} diff --git a/src/main/java/com/mvrent/carrental/model/InspectionRecord.java b/src/main/java/com/mvrent/carrental/model/InspectionRecord.java new file mode 100644 index 0000000..7be2835 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/InspectionRecord.java @@ -0,0 +1,65 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** A pre/post-trip or periodic condition & damage inspection of a car. */ +@Entity +@Table(name = "inspection_records") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InspectionRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + /** Optional link to the trip this inspection belongs to. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "booking_id") + private Booking booking; + + @Enumerated(EnumType.STRING) + @NotNull + private InspectionType type; + + @Enumerated(EnumType.STRING) + @NotNull + @Builder.Default + private InspectionCondition condition = InspectionCondition.GOOD; + + private LocalDate inspectionDate; + + private String inspector; + + private Integer odometer; + + /** Fuel level as a percentage 0–100. */ + private Integer fuelLevel; + + @Column(length = 1000) + private String damageNotes; + + /** Path/URL to an uploaded photo of the damage / condition. */ + private String photoUrl; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Transient + public boolean hasDamage() { + return condition != null && condition != InspectionCondition.GOOD; + } +} diff --git a/src/main/java/com/mvrent/carrental/model/InspectionType.java b/src/main/java/com/mvrent/carrental/model/InspectionType.java new file mode 100644 index 0000000..183592e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/InspectionType.java @@ -0,0 +1,9 @@ +package com.mvrent.carrental.model; + +/** When a vehicle inspection was carried out. */ +public enum InspectionType { + PRE_TRIP, + POST_TRIP, + PERIODIC, + INCIDENT +} diff --git a/src/main/java/com/mvrent/carrental/model/Invoice.java b/src/main/java/com/mvrent/carrental/model/Invoice.java new file mode 100644 index 0000000..b2ef4d1 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Invoice.java @@ -0,0 +1,41 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** A customer invoice generated for a booking. */ +@Entity +@Table(name = "invoices") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Invoice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "booking_id", unique = true) + private Booking booking; + + @Column(unique = true, nullable = false) + private String invoiceNumber; + + @Column(nullable = false, updatable = false) + private LocalDateTime issuedAt; + + private BigDecimal subtotal; + private BigDecimal taxAmount; + private BigDecimal total; + + @PrePersist + void onCreate() { + if (issuedAt == null) issuedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/MaintenanceRecord.java b/src/main/java/com/mvrent/carrental/model/MaintenanceRecord.java new file mode 100644 index 0000000..218dd3f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/MaintenanceRecord.java @@ -0,0 +1,70 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** A service / repair entry in a car's maintenance history. */ +@Entity +@Table(name = "maintenance_records") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class MaintenanceRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + @Enumerated(EnumType.STRING) + @NotNull + private MaintenanceType type; + + @Enumerated(EnumType.STRING) + @NotNull + @Builder.Default + private MaintenanceStatus status = MaintenanceStatus.COMPLETED; + + private LocalDate serviceDate; + + /** Odometer reading at the time of service (km). */ + private Integer odometer; + + private BigDecimal cost; + + /** Garage / workshop that did the work. */ + private String garage; + + @Column(length = 1000) + private String notes; + + private LocalDate nextServiceDate; + + private Integer nextServiceOdometer; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Transient + public boolean isOpen() { + return status != MaintenanceStatus.COMPLETED; + } + + @Transient + public boolean isDueSoon() { + return nextServiceDate != null + && !nextServiceDate.isBefore(LocalDate.now()) + && nextServiceDate.isBefore(LocalDate.now().plusDays(30)); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/MaintenanceStatus.java b/src/main/java/com/mvrent/carrental/model/MaintenanceStatus.java new file mode 100644 index 0000000..ec91ec5 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/MaintenanceStatus.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Lifecycle of a maintenance job. An open job (SCHEDULED/IN_PROGRESS) takes the car off the road. */ +public enum MaintenanceStatus { + SCHEDULED, + IN_PROGRESS, + COMPLETED +} diff --git a/src/main/java/com/mvrent/carrental/model/MaintenanceType.java b/src/main/java/com/mvrent/carrental/model/MaintenanceType.java new file mode 100644 index 0000000..a81ab82 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/MaintenanceType.java @@ -0,0 +1,13 @@ +package com.mvrent.carrental.model; + +/** Kind of work done on a vehicle. */ +public enum MaintenanceType { + SERVICE, + REPAIR, + TYRE, + BATTERY, + BODY_WORK, + CLEANING, + INSURANCE_RENEWAL, + OTHER +} diff --git a/src/main/java/com/mvrent/carrental/model/Organization.java b/src/main/java/com/mvrent/carrental/model/Organization.java new file mode 100644 index 0000000..f63221f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Organization.java @@ -0,0 +1,43 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +/** + * The single rental company that owns this installation. One row only (id = 1). + * Branding here is applied to the customer page and exported reports. + */ +@Entity +@Table(name = "organization") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Organization { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String name; + + private String legalName; + + private String logoUrl; + + private String address; + + private String gstNumber; + + @Email + private String contactEmail; + + private String phone; + + @NotBlank + @Builder.Default + private String currencyCode = "INR"; +} diff --git a/src/main/java/com/mvrent/carrental/model/Payment.java b/src/main/java/com/mvrent/carrental/model/Payment.java new file mode 100644 index 0000000..8ce0d0e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Payment.java @@ -0,0 +1,50 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** A payment collected against a booking (counter cash, UPI, card, gateway, …). */ +@Entity +@Table(name = "payments") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "booking_id") + private Booking booking; + + @NotNull + private BigDecimal amount; + + @Enumerated(EnumType.STRING) + @NotNull + @Builder.Default + private PaymentMethod method = PaymentMethod.CASH; + + /** Transaction / cheque / UPI reference. */ + private String reference; + + private LocalDate paidAt; + + private String notes; + + /** Username of the staff member who recorded it. */ + private String recordedBy; + + @CreationTimestamp + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/mvrent/carrental/model/PaymentMethod.java b/src/main/java/com/mvrent/carrental/model/PaymentMethod.java new file mode 100644 index 0000000..5f632a6 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/PaymentMethod.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental.model; + +/** How a payment was collected. */ +public enum PaymentMethod { + CASH, + UPI, + CARD, + BANK_TRANSFER, + ONLINE, + OTHER +} diff --git a/src/main/java/com/mvrent/carrental/model/Permission.java b/src/main/java/com/mvrent/carrental/model/Permission.java new file mode 100644 index 0000000..873f072 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Permission.java @@ -0,0 +1,16 @@ +package com.mvrent.carrental.model; + +/** + * Granular capabilities assignable per staff user (see {@link User#getPermissions()}). + * ADMIN implicitly has all of these; STAFF gets a configurable subset. + */ +public enum Permission { + MANAGE_FLEET, + MANAGE_DRIVERS, + MANAGE_VENDORS, + MANAGE_BOOKINGS, + MANAGE_BILLING, + VIEW_REPORTS, + MANAGE_CONFIG, + MANAGE_USERS +} diff --git a/src/main/java/com/mvrent/carrental/model/RentalUnit.java b/src/main/java/com/mvrent/carrental/model/RentalUnit.java new file mode 100644 index 0000000..68721d1 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/RentalUnit.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Billing period for a booking. HOUR is reserved for a future release. */ +public enum RentalUnit { + HOUR, + DAY, + MONTH +} diff --git a/src/main/java/com/mvrent/carrental/model/Review.java b/src/main/java/com/mvrent/carrental/model/Review.java new file mode 100644 index 0000000..80f2384 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Review.java @@ -0,0 +1,51 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** A customer's star rating and comment for a car. */ +@Entity +@Table(name = "reviews") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Review { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "car_id") + private Car car; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "user_id") + private User user; + + /** The trip this review relates to (optional). */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "booking_id") + private Booking booking; + + @Min(1) + @Max(5) + private int rating; + + @Column(length = 1000) + private String comment; + + /** Published reviews are shown publicly; admins can unpublish. */ + @Builder.Default + private boolean approved = true; + + @CreationTimestamp + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/mvrent/carrental/model/Role.java b/src/main/java/com/mvrent/carrental/model/Role.java new file mode 100644 index 0000000..b9fea76 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Role.java @@ -0,0 +1,14 @@ +package com.mvrent.carrental.model; + +/** + * Top-level account type. ADMIN/STAFF use the /admin chain, VENDOR the /vendor + * portal, DRIVER the /driver portal, CUSTOMER the public site. Fine-grained + * access for staff is controlled by {@link Permission}. + */ +public enum Role { + ADMIN, + STAFF, + VENDOR, + DRIVER, + CUSTOMER +} diff --git a/src/main/java/com/mvrent/carrental/model/SettlementCycle.java b/src/main/java/com/mvrent/carrental/model/SettlementCycle.java new file mode 100644 index 0000000..5feede3 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/SettlementCycle.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.model; + +/** How often vendor settlements are aggregated. */ +public enum SettlementCycle { + WEEKLY, + MONTHLY +} diff --git a/src/main/java/com/mvrent/carrental/model/SettlementStatus.java b/src/main/java/com/mvrent/carrental/model/SettlementStatus.java new file mode 100644 index 0000000..cfd350d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/SettlementStatus.java @@ -0,0 +1,8 @@ +package com.mvrent.carrental.model; + +/** Lifecycle of a vendor settlement statement. */ +public enum SettlementStatus { + DRAFT, + FINALISED, + PAID +} diff --git a/src/main/java/com/mvrent/carrental/model/SystemConfig.java b/src/main/java/com/mvrent/carrental/model/SystemConfig.java new file mode 100644 index 0000000..9223e5f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/SystemConfig.java @@ -0,0 +1,46 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +/** + * Master configuration singleton (id = 1): currency, tax, default charges and + * commission rules used across booking, billing and settlement calculations. + */ +@Entity +@Table(name = "system_config") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SystemConfig { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Builder.Default + private String currencyCode = "INR"; + + /** Default GST/tax percent applied to bookings, e.g. 18.00 */ + @Builder.Default + private BigDecimal defaultTaxPercent = new BigDecimal("18.00"); + + /** Default organization commission on vendor revenue, e.g. 15.00 */ + @Builder.Default + private BigDecimal defaultCommissionPercent = new BigDecimal("15.00"); + + /** Fallback driver day charge when a driver has none set. */ + @Builder.Default + private BigDecimal defaultDriverDailyCharge = new BigDecimal("800.00"); + + @Builder.Default + private String invoicePrefix = "INV"; + + @Enumerated(EnumType.STRING) + @Builder.Default + private SettlementCycle settlementCycle = SettlementCycle.MONTHLY; +} diff --git a/src/main/java/com/mvrent/carrental/model/Transmission.java b/src/main/java/com/mvrent/carrental/model/Transmission.java new file mode 100644 index 0000000..4f32a71 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Transmission.java @@ -0,0 +1,5 @@ +package com.mvrent.carrental.model; + +public enum Transmission { + MANUAL, AUTOMATIC +} diff --git a/src/main/java/com/mvrent/carrental/model/User.java b/src/main/java/com/mvrent/carrental/model/User.java new file mode 100644 index 0000000..7bc668e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/User.java @@ -0,0 +1,73 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "users") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + @NotBlank + @Size(min = 3, max = 50) + private String username; + + @Column(unique = true, nullable = false) + @Email + @NotBlank + private String email; + + // Set programmatically (encoded) by UserService/registration, not bound from the form. + @Column(nullable = false) + private String password; + + @NotBlank + private String fullName; + + private String phone; + + private String licenseNumber; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + /** Set for ROLE_VENDOR portal users — links the login to its vendor. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "vendor_id") + private Vendor vendor; + + /** Set for ROLE_DRIVER portal users — links the login to its driver record. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "driver_id") + private Driver driver; + + /** Customer segment (master category of type CUSTOMER); optional. */ + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "customer_category_id") + private Category customerCategory; + + /** Granular staff capabilities; empty for customers/vendors. */ + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "user_permissions", joinColumns = @JoinColumn(name = "user_id")) + @Enumerated(EnumType.STRING) + @Column(name = "permission") + @Builder.Default + private Set permissions = new HashSet<>(); + + @Builder.Default + private boolean enabled = true; +} diff --git a/src/main/java/com/mvrent/carrental/model/VehicleLocation.java b/src/main/java/com/mvrent/carrental/model/VehicleLocation.java new file mode 100644 index 0000000..5a6d530 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/VehicleLocation.java @@ -0,0 +1,47 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** The latest known GPS position / telematics state for a car (one row per car). */ +@Entity +@Table(name = "vehicle_locations") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VehicleLocation { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "car_id", unique = true) + private Car car; + + private double latitude; + + private double longitude; + + /** Speed in km/h. */ + private Double speedKph; + + /** Heading in degrees (0–360). */ + private Double heading; + + private Integer odometer; + + private Boolean ignitionOn; + + private LocalDateTime updatedAt; + + /** Considered "live" if a ping arrived in the last 5 minutes. */ + @Transient + public boolean isLive() { + return updatedAt != null && updatedAt.isAfter(LocalDateTime.now().minusMinutes(5)); + } +} diff --git a/src/main/java/com/mvrent/carrental/model/Vendor.java b/src/main/java/com/mvrent/carrental/model/Vendor.java new file mode 100644 index 0000000..df91c5f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/Vendor.java @@ -0,0 +1,50 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +/** + * A third party that attaches cars/drivers to the fleet. Vendors get a scoped + * /vendor portal login and earn revenue minus the organization's commission. + */ +@Entity +@Table(name = "vendors") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Vendor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + private String name; + + private String contactPerson; + + private String phone; + + @Email + private String email; + + private String address; + + private String gstNumber; + + /** Free-text bank / settlement details. */ + private String settlementDetails; + + /** Per-vendor commission percent; overrides the global default when set. */ + private BigDecimal commissionPercent; + + @ColumnDefault("true") + @Builder.Default + private boolean active = true; +} diff --git a/src/main/java/com/mvrent/carrental/model/VendorSettlement.java b/src/main/java/com/mvrent/carrental/model/VendorSettlement.java new file mode 100644 index 0000000..11335e9 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/model/VendorSettlement.java @@ -0,0 +1,55 @@ +package com.mvrent.carrental.model; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * An aggregated settlement statement for a vendor over a period: + * gross rental revenue, organization commission, vendor driver charges and the + * resulting net payable. + */ +@Entity +@Table(name = "vendor_settlements") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VendorSettlement { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "vendor_id") + private Vendor vendor; + + private LocalDate periodStart; + private LocalDate periodEnd; + + @Builder.Default + private int tripCount = 0; + + private BigDecimal grossRevenue; + private BigDecimal commissionAmount; + private BigDecimal driverCharges; + private BigDecimal netPayable; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @Builder.Default + private SettlementStatus status = SettlementStatus.DRAFT; + + @Column(nullable = false, updatable = false) + private LocalDateTime generatedAt; + + @PrePersist + void onCreate() { + if (generatedAt == null) generatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/mvrent/carrental/repository/AuditLogRepository.java b/src/main/java/com/mvrent/carrental/repository/AuditLogRepository.java new file mode 100644 index 0000000..30dda78 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/AuditLogRepository.java @@ -0,0 +1,12 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.AuditLog; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface AuditLogRepository extends JpaRepository { + + List findAllByOrderByCreatedAtDesc(Pageable pageable); +} diff --git a/src/main/java/com/mvrent/carrental/repository/BookingRepository.java b/src/main/java/com/mvrent/carrental/repository/BookingRepository.java new file mode 100644 index 0000000..af601ea --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/BookingRepository.java @@ -0,0 +1,47 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.BookingStatus; +import com.mvrent.carrental.model.CarOwnership; +import com.mvrent.carrental.model.Driver; +import com.mvrent.carrental.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.time.LocalDate; +import java.util.List; + +public interface BookingRepository extends JpaRepository, + JpaSpecificationExecutor { + + List findByUserOrderByCreatedAtDesc(User user); + + List findByDriverOrderByCreatedAtDesc(Driver driver); + + long countByDriver(Driver driver); + + List findByCarVendorIdOrderByCreatedAtDesc(Long vendorId); + + long countByCarVendorId(Long vendorId); + + List findByCarVendorIdAndStatusInAndPickupDateBetweenOrderByPickupDate( + Long vendorId, List statuses, LocalDate start, LocalDate end); + + List findByRevenueOwnerAndStatusIn(CarOwnership owner, List statuses); + + List findAllByOrderByCreatedAtDesc(); + + boolean existsByCarIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual( + Long carId, + List statuses, + LocalDate returnDate, + LocalDate pickupDate + ); + + boolean existsByDriverIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual( + Long driverId, + List statuses, + LocalDate returnDate, + LocalDate pickupDate + ); +} diff --git a/src/main/java/com/mvrent/carrental/repository/CarDocumentRepository.java b/src/main/java/com/mvrent/carrental/repository/CarDocumentRepository.java new file mode 100644 index 0000000..cc87e0b --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/CarDocumentRepository.java @@ -0,0 +1,20 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.CarDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface CarDocumentRepository extends JpaRepository { + + /** Documents already expired or expiring on/before the given cut-off date. */ + @Query(""" + SELECT d FROM CarDocument d + WHERE d.expiryDate IS NOT NULL AND d.expiryDate <= :cutoff + ORDER BY d.expiryDate ASC + """) + List findExpiringOrExpired(@Param("cutoff") LocalDate cutoff); +} diff --git a/src/main/java/com/mvrent/carrental/repository/CarPhotoRepository.java b/src/main/java/com/mvrent/carrental/repository/CarPhotoRepository.java new file mode 100644 index 0000000..0b4d1e3 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/CarPhotoRepository.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.CarPhoto; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CarPhotoRepository extends JpaRepository { +} diff --git a/src/main/java/com/mvrent/carrental/repository/CarRepository.java b/src/main/java/com/mvrent/carrental/repository/CarRepository.java new file mode 100644 index 0000000..370b47e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/CarRepository.java @@ -0,0 +1,37 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Car; +import com.mvrent.carrental.model.CarStatus; +import com.mvrent.carrental.model.CarType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface CarRepository extends JpaRepository { + + List findByAvailableTrueAndStatus(CarStatus status); + + List findByType(CarType type); + + List findByVendorIdOrderByMake(Long vendorId); + + long countByVendorId(Long vendorId); + + @Query(""" + SELECT c FROM Car c + WHERE c.available = true + AND c.status = com.mvrent.carrental.model.CarStatus.ACTIVE + AND c.id NOT IN ( + SELECT b.car.id FROM Booking b + WHERE b.status IN (com.mvrent.carrental.model.BookingStatus.PENDING, + com.mvrent.carrental.model.BookingStatus.CONFIRMED) + AND b.pickupDate <= :returnDate + AND b.returnDate >= :pickupDate + ) + """) + List findAvailableBetween(@Param("pickupDate") LocalDate pickupDate, + @Param("returnDate") LocalDate returnDate); +} diff --git a/src/main/java/com/mvrent/carrental/repository/CategoryRepository.java b/src/main/java/com/mvrent/carrental/repository/CategoryRepository.java new file mode 100644 index 0000000..e22134b --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/CategoryRepository.java @@ -0,0 +1,14 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Category; +import com.mvrent.carrental.model.CategoryType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CategoryRepository extends JpaRepository { + List findByTypeOrderByName(CategoryType type); + List findByTypeAndActiveTrueOrderByName(CategoryType type); + List findAllByOrderByTypeAscNameAsc(); + boolean existsByTypeAndName(CategoryType type, String name); +} diff --git a/src/main/java/com/mvrent/carrental/repository/DriverDocumentRepository.java b/src/main/java/com/mvrent/carrental/repository/DriverDocumentRepository.java new file mode 100644 index 0000000..779ae8e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/DriverDocumentRepository.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.DriverDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; + +public interface DriverDocumentRepository extends JpaRepository { + + @Query(""" + SELECT d FROM DriverDocument d + WHERE d.expiryDate IS NOT NULL AND d.expiryDate <= :cutoff + ORDER BY d.expiryDate ASC + """) + List findExpiringOrExpired(@Param("cutoff") LocalDate cutoff); +} diff --git a/src/main/java/com/mvrent/carrental/repository/DriverRepository.java b/src/main/java/com/mvrent/carrental/repository/DriverRepository.java new file mode 100644 index 0000000..3ea46ff --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/DriverRepository.java @@ -0,0 +1,14 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Driver; +import com.mvrent.carrental.model.DriverStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface DriverRepository extends JpaRepository { + List findAllByOrderByName(); + List findByStatus(DriverStatus status); + List findByVendorIdOrderByName(Long vendorId); + boolean existsByLicenceNumber(String licenceNumber); +} diff --git a/src/main/java/com/mvrent/carrental/repository/InspectionRepository.java b/src/main/java/com/mvrent/carrental/repository/InspectionRepository.java new file mode 100644 index 0000000..e955e23 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/InspectionRepository.java @@ -0,0 +1,13 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.InspectionRecord; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface InspectionRepository extends JpaRepository { + + List findAllByOrderByInspectionDateDescIdDesc(); + + List findByCarIdOrderByInspectionDateDescIdDesc(Long carId); +} diff --git a/src/main/java/com/mvrent/carrental/repository/InvoiceRepository.java b/src/main/java/com/mvrent/carrental/repository/InvoiceRepository.java new file mode 100644 index 0000000..1d635d4 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/InvoiceRepository.java @@ -0,0 +1,10 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Invoice; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface InvoiceRepository extends JpaRepository { + Optional findByBookingId(Long bookingId); +} diff --git a/src/main/java/com/mvrent/carrental/repository/MaintenanceRepository.java b/src/main/java/com/mvrent/carrental/repository/MaintenanceRepository.java new file mode 100644 index 0000000..fe5555e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/MaintenanceRepository.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.MaintenanceRecord; +import com.mvrent.carrental.model.MaintenanceStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface MaintenanceRepository extends JpaRepository { + + List findAllByOrderByServiceDateDescIdDesc(); + + List findByCarIdOrderByServiceDateDescIdDesc(Long carId); + + boolean existsByCarIdAndStatusIn(Long carId, Collection statuses); + + long countByStatusIn(Collection statuses); +} diff --git a/src/main/java/com/mvrent/carrental/repository/OrganizationRepository.java b/src/main/java/com/mvrent/carrental/repository/OrganizationRepository.java new file mode 100644 index 0000000..8d75384 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/OrganizationRepository.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Organization; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrganizationRepository extends JpaRepository { +} diff --git a/src/main/java/com/mvrent/carrental/repository/PaymentRepository.java b/src/main/java/com/mvrent/carrental/repository/PaymentRepository.java new file mode 100644 index 0000000..b3aa0f8 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/PaymentRepository.java @@ -0,0 +1,20 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Payment; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; +import java.util.List; + +public interface PaymentRepository extends JpaRepository { + + List findByBookingIdOrderByPaidAtDescIdDesc(Long bookingId); + + @Query("SELECT COALESCE(SUM(p.amount), 0) FROM Payment p WHERE p.booking.id = :bookingId") + BigDecimal sumForBooking(@Param("bookingId") Long bookingId); + + @Query("SELECT p.booking.id, COALESCE(SUM(p.amount), 0) FROM Payment p GROUP BY p.booking.id") + List sumGroupedByBooking(); +} diff --git a/src/main/java/com/mvrent/carrental/repository/ReviewRepository.java b/src/main/java/com/mvrent/carrental/repository/ReviewRepository.java new file mode 100644 index 0000000..a165c06 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/ReviewRepository.java @@ -0,0 +1,23 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Review; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ReviewRepository extends JpaRepository { + + List findByCarIdAndApprovedTrueOrderByCreatedAtDesc(Long carId); + + List findAllByOrderByCreatedAtDesc(); + + boolean existsByBookingId(Long bookingId); + + @Query("SELECT AVG(r.rating) FROM Review r WHERE r.car.id = :carId AND r.approved = true") + Double averageForCar(@Param("carId") Long carId); + + @Query("SELECT COUNT(r) FROM Review r WHERE r.car.id = :carId AND r.approved = true") + long countApprovedForCar(@Param("carId") Long carId); +} diff --git a/src/main/java/com/mvrent/carrental/repository/SystemConfigRepository.java b/src/main/java/com/mvrent/carrental/repository/SystemConfigRepository.java new file mode 100644 index 0000000..db371b5 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/SystemConfigRepository.java @@ -0,0 +1,7 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.SystemConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SystemConfigRepository extends JpaRepository { +} diff --git a/src/main/java/com/mvrent/carrental/repository/UserRepository.java b/src/main/java/com/mvrent/carrental/repository/UserRepository.java new file mode 100644 index 0000000..e150c6c --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/UserRepository.java @@ -0,0 +1,17 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + boolean existsByUsername(String username); + boolean existsByEmail(String email); + long countByRole(com.mvrent.carrental.model.Role role); + java.util.List findByRoleOrderByFullName(com.mvrent.carrental.model.Role role); + java.util.List findByRoleInOrderByFullName(java.util.Collection roles); + long countByRoleAndEnabledTrue(com.mvrent.carrental.model.Role role); +} diff --git a/src/main/java/com/mvrent/carrental/repository/VehicleLocationRepository.java b/src/main/java/com/mvrent/carrental/repository/VehicleLocationRepository.java new file mode 100644 index 0000000..d353ecf --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/VehicleLocationRepository.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.VehicleLocation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface VehicleLocationRepository extends JpaRepository { + + Optional findByCarId(Long carId); +} diff --git a/src/main/java/com/mvrent/carrental/repository/VendorRepository.java b/src/main/java/com/mvrent/carrental/repository/VendorRepository.java new file mode 100644 index 0000000..aa5442f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/VendorRepository.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.Vendor; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface VendorRepository extends JpaRepository { + List findAllByOrderByName(); + List findByActiveTrueOrderByName(); +} diff --git a/src/main/java/com/mvrent/carrental/repository/VendorSettlementRepository.java b/src/main/java/com/mvrent/carrental/repository/VendorSettlementRepository.java new file mode 100644 index 0000000..8667f36 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/repository/VendorSettlementRepository.java @@ -0,0 +1,11 @@ +package com.mvrent.carrental.repository; + +import com.mvrent.carrental.model.VendorSettlement; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface VendorSettlementRepository extends JpaRepository { + List findByVendorIdOrderByGeneratedAtDesc(Long vendorId); + List findAllByOrderByGeneratedAtDesc(); +} diff --git a/src/main/java/com/mvrent/carrental/service/AuditService.java b/src/main/java/com/mvrent/carrental/service/AuditService.java new file mode 100644 index 0000000..07356d5 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/AuditService.java @@ -0,0 +1,63 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.AuditLog; +import com.mvrent.carrental.repository.AuditLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.List; + +/** + * Builds audit entries from the current security context / request and hands + * them to {@link AuditWriter} to persist in an independent transaction. + */ +@Service +@RequiredArgsConstructor +public class AuditService { + + private final AuditLogRepository auditLogRepository; + private final AuditWriter auditWriter; + + public List recent(int limit) { + return auditLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(0, limit)); + } + + /** Log an action performed by the currently authenticated principal. */ + public void log(String action, String entityType, String entityId, String detail) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = (auth != null && auth.isAuthenticated()) ? auth.getName() : "system"; + String role = (auth != null && auth.getAuthorities() != null) + ? auth.getAuthorities().stream().map(Object::toString) + .filter(a -> a.startsWith("ROLE_")).findFirst().map(a -> a.substring(5)).orElse(null) + : null; + auditWriter.write(AuditLog.builder() + .username(username).role(role).action(action) + .entityType(entityType).entityId(entityId) + .detail(detail).ip(currentIp()) + .build()); + } + + /** Log an authentication event with an explicit username. */ + public void logAuth(String action, String username) { + auditWriter.write(AuditLog.builder() + .username(username).action(action).entityType("Auth").ip(currentIp()) + .build()); + } + + private static String currentIp() { + try { + var attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs == null) return null; + var req = attrs.getRequest(); + String fwd = req.getHeader("X-Forwarded-For"); + return (fwd != null && !fwd.isBlank()) ? fwd.split(",")[0].trim() : req.getRemoteAddr(); + } catch (Exception ex) { + return null; + } + } +} diff --git a/src/main/java/com/mvrent/carrental/service/AuditWriter.java b/src/main/java/com/mvrent/carrental/service/AuditWriter.java new file mode 100644 index 0000000..124618e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/AuditWriter.java @@ -0,0 +1,31 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.AuditLog; +import com.mvrent.carrental.repository.AuditLogRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * Persists a single audit row in its own transaction. Kept in a separate bean + * so {@code REQUIRES_NEW} actually takes effect (not bypassed by self-invocation) + * and a logging failure never rolls back the business operation. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class AuditWriter { + + private final AuditLogRepository auditLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void write(AuditLog entry) { + try { + auditLogRepository.save(entry); + } catch (Exception ex) { + log.debug("Audit write skipped: {}", ex.getMessage()); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/service/BookingFilter.java b/src/main/java/com/mvrent/carrental/service/BookingFilter.java new file mode 100644 index 0000000..f40a469 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/BookingFilter.java @@ -0,0 +1,23 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.BookingStatus; +import com.mvrent.carrental.model.CarOwnership; + +import java.time.LocalDate; + +/** Optional filters for the bookings report (any field may be null = no filter). */ +public record BookingFilter( + LocalDate from, + LocalDate to, + Long carId, + Long driverId, + Long vendorId, + CarOwnership ownership, + BookingStatus status, + Long customerCategoryId +) { + public boolean isEmpty() { + return from == null && to == null && carId == null && driverId == null + && vendorId == null && ownership == null && status == null && customerCategoryId == null; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/BookingService.java b/src/main/java/com/mvrent/carrental/service/BookingService.java new file mode 100644 index 0000000..ba2e7c0 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/BookingService.java @@ -0,0 +1,192 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.BookingRepository; +import com.mvrent.carrental.repository.DriverRepository; +import com.mvrent.carrental.repository.InvoiceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookingService { + + private static final List ACTIVE_STATUSES = + List.of(BookingStatus.PENDING, BookingStatus.CONFIRMED); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + + private final BookingRepository bookingRepository; + private final CarService carService; + private final DriverRepository driverRepository; + private final ConfigService configService; + private final CommissionService commissionService; + private final InvoiceRepository invoiceRepository; + private final NotificationService notificationService; + private final SmsService smsService; + + @Transactional + public Booking createBooking(User user, Long carId, Booking form, Long driverId) { + if (!form.getReturnDate().isAfter(form.getPickupDate())) { + throw new IllegalArgumentException("Return date must be after pickup date"); + } + + Car car = carService.findById(carId); + if (!car.isBookable()) { + throw new IllegalStateException( + car.getStatus() == CarStatus.MAINTENANCE + ? "Car is under maintenance and cannot be booked" + : "Car is not available for booking"); + } + if (carOverlaps(carId, form)) { + throw new IllegalStateException("Car is already booked for the selected dates"); + } + + SystemConfig cfg = configService.getSystemConfig(); + RentalUnit unit = form.getRentalUnit() != null ? form.getRentalUnit() : RentalUnit.DAY; + + // ----- Driver assignment ----- + Driver driver = null; + boolean withDriver = form.isWithDriver(); + if (withDriver) { + if (driverId == null) { + throw new IllegalArgumentException("Please select a driver for a with-driver booking"); + } + driver = driverRepository.findById(driverId) + .orElseThrow(() -> new IllegalArgumentException("Driver not found")); + if (!driver.isAssignable()) { + throw new IllegalStateException("Selected driver is not available (off duty or licence expired)"); + } + boolean driverBusy = bookingRepository + .existsByDriverIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual( + driverId, ACTIVE_STATUSES, form.getReturnDate(), form.getPickupDate()); + if (driverBusy) { + throw new IllegalStateException("Driver is already assigned for the selected dates"); + } + } + + // ----- Period & vehicle charge ----- + long qty; + BigDecimal vehicleCharge; + if (unit == RentalUnit.MONTH) { + qty = Math.max(1, ChronoUnit.MONTHS.between(form.getPickupDate(), form.getReturnDate())); + BigDecimal monthly = car.getMonthlyRate() != null + ? car.getMonthlyRate() + : car.getPricePerDay().multiply(BigDecimal.valueOf(30)); + vehicleCharge = monthly.multiply(BigDecimal.valueOf(qty)); + } else { // DAY (HOUR not offered in v1) + qty = Math.max(1, ChronoUnit.DAYS.between(form.getPickupDate(), form.getReturnDate())); + vehicleCharge = car.getPricePerDay().multiply(BigDecimal.valueOf(qty)); + } + + // ----- Driver charge ----- + BigDecimal driverCharge = BigDecimal.ZERO; + if (withDriver && driver != null) { + if (unit == RentalUnit.MONTH) { + BigDecimal monthly = driver.getMonthlyCharge() != null ? driver.getMonthlyCharge() + : (driver.getDailyCharge() != null ? driver.getDailyCharge() : cfg.getDefaultDriverDailyCharge()) + .multiply(BigDecimal.valueOf(30)); + driverCharge = monthly.multiply(BigDecimal.valueOf(qty)); + } else { + BigDecimal daily = driver.getDailyCharge() != null + ? driver.getDailyCharge() : cfg.getDefaultDriverDailyCharge(); + driverCharge = daily.multiply(BigDecimal.valueOf(qty)); + } + } + + // ----- Tax & total ----- + BigDecimal taxPercent = cfg.getDefaultTaxPercent(); + BigDecimal subtotal = vehicleCharge.add(driverCharge); + BigDecimal taxAmount = subtotal.multiply(taxPercent) + .divide(HUNDRED, 2, RoundingMode.HALF_UP); + BigDecimal total = subtotal.add(taxAmount); + + // ----- Revenue attribution ----- + BigDecimal commissionPercent = commissionService.resolvePercent(car); + BigDecimal commissionAmount = commissionService.commissionAmount(car, vehicleCharge); + + Booking booking = Booking.builder() + .user(user).car(car).driver(driver).withDriver(withDriver) + .pickupDate(form.getPickupDate()).returnDate(form.getReturnDate()) + .pickupLocation(form.getPickupLocation()).dropLocation(form.getDropLocation()) + .rentalUnit(unit).quantity((int) qty) + .vehicleCharge(vehicleCharge).driverCharge(driverCharge) + .taxPercent(taxPercent).taxAmount(taxAmount).totalAmount(total) + .revenueOwner(car.getOwnership()).vendor(car.getVendor()) + .commissionPercent(commissionPercent).commissionAmount(commissionAmount) + .status(BookingStatus.PENDING) + .build(); + booking = bookingRepository.save(booking); + + // ----- Invoice ----- + Invoice invoice = Invoice.builder() + .booking(booking) + .invoiceNumber(cfg.getInvoicePrefix() + "-" + String.format("%05d", booking.getId())) + .subtotal(subtotal).taxAmount(taxAmount).total(total) + .build(); + invoiceRepository.save(invoice); + + // Notify the customer (fail-safe; no-ops if email/SMS not configured). + notificationService.sendBookingConfirmation(booking); + smsService.sendBookingConfirmation(booking); + + return booking; + } + + private boolean carOverlaps(Long carId, Booking form) { + return bookingRepository + .existsByCarIdAndStatusInAndPickupDateLessThanEqualAndReturnDateGreaterThanEqual( + carId, ACTIVE_STATUSES, form.getReturnDate(), form.getPickupDate()); + } + + public List findAll() { + return bookingRepository.findAllByOrderByCreatedAtDesc(); + } + + /** Owned vs vendor rental-revenue split across all bookings. */ + public record RevenueSplit(BigDecimal owned, BigDecimal vendor) { + public BigDecimal total() { return owned.add(vendor); } + } + + public RevenueSplit revenueSplit() { + BigDecimal owned = BigDecimal.ZERO; + BigDecimal vendor = BigDecimal.ZERO; + for (Booking b : bookingRepository.findAllByOrderByCreatedAtDesc()) { + BigDecimal amt = b.getVehicleCharge() != null ? b.getVehicleCharge() + : (b.getTotalAmount() == null ? BigDecimal.ZERO : b.getTotalAmount()); + if (b.getRevenueOwner() == CarOwnership.VENDOR) { + vendor = vendor.add(amt); + } else { + owned = owned.add(amt); + } + } + return new RevenueSplit(owned, vendor); + } + + public List findByUser(User user) { + return bookingRepository.findByUserOrderByCreatedAtDesc(user); + } + + public Booking findById(Long id) { + return bookingRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Booking not found: " + id)); + } + + public Invoice invoiceFor(Long bookingId) { + return invoiceRepository.findByBookingId(bookingId) + .orElseThrow(() -> new IllegalArgumentException("Invoice not found for booking " + bookingId)); + } + + @Transactional + public Booking updateStatus(Long id, BookingStatus status) { + Booking booking = findById(id); + booking.setStatus(status); + notificationService.sendBookingStatusUpdate(booking); + return booking; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/CarService.java b/src/main/java/com/mvrent/carrental/service/CarService.java new file mode 100644 index 0000000..70ce44d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/CarService.java @@ -0,0 +1,121 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.CarDocumentRepository; +import com.mvrent.carrental.repository.CarPhotoRepository; +import com.mvrent.carrental.repository.CarRepository; +import com.mvrent.carrental.repository.VendorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class CarService { + + private final CarRepository carRepository; + private final CarDocumentRepository carDocumentRepository; + private final CarPhotoRepository carPhotoRepository; + private final VendorRepository vendorRepository; + + public List findAll() { + return carRepository.findAll(); + } + + /** Cars listed and operationally active (used by the public site). */ + public List findAvailable() { + return carRepository.findByAvailableTrueAndStatus(CarStatus.ACTIVE); + } + + public List findAvailableBetween(LocalDate pickup, LocalDate ret) { + return carRepository.findAvailableBetween(pickup, ret); + } + + public Car findById(Long id) { + return carRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Car not found: " + id)); + } + + public Car save(Car car) { + return carRepository.save(car); + } + + /** + * Persist a car from the admin form. For an existing car the scalar fields are + * copied onto the managed entity so its documents/photos collections are kept. + */ + @Transactional + public Car saveFromForm(Car form, Set featureTags, Long vendorId) { + Car target = form.getId() != null ? findById(form.getId()) : new Car(); + target.setMake(form.getMake()); + target.setModel(form.getModel()); + target.setRegistrationNumber(form.getRegistrationNumber()); + target.setYearOfRegistration(form.getYearOfRegistration()); + target.setColour(form.getColour()); + target.setType(form.getType()); + target.setSeats(form.getSeats()); + target.setTransmission(form.getTransmission()); + target.setFuelType(form.getFuelType()); + target.setHourlyRate(form.getHourlyRate()); + target.setPricePerDay(form.getPricePerDay()); + target.setMonthlyRate(form.getMonthlyRate()); + target.setOwnership(form.getOwnership()); + target.setUnitsTotal(form.getUnitsTotal()); + target.setStatus(form.getStatus()); + target.setImageUrl(form.getImageUrl()); + target.setAvailable(form.isAvailable()); + target.setFeatureTags(featureTags); + // Vendor link only applies to vendor-attached cars. + if (form.getOwnership() == CarOwnership.VENDOR && vendorId != null) { + target.setVendor(vendorRepository.findById(vendorId).orElse(null)); + target.setCommissionPercentOverride(form.getCommissionPercentOverride()); + } else { + target.setVendor(null); + target.setCommissionPercentOverride(null); + } + return carRepository.save(target); + } + + public void delete(Long id) { + carRepository.deleteById(id); + } + + /* ---------- Documents ---------- */ + + @Transactional + public CarDocument addDocument(Long carId, CarDocumentType type, String fileUrl, + LocalDate issued, LocalDate expiry) { + Car car = findById(carId); + CarDocument doc = CarDocument.builder() + .car(car).docType(type).fileUrl(fileUrl) + .issuedDate(issued).expiryDate(expiry).build(); + return carDocumentRepository.save(doc); + } + + @Transactional + public void deleteDocument(Long docId) { + carDocumentRepository.deleteById(docId); + } + + public List documentsExpiringWithin(int days) { + return carDocumentRepository.findExpiringOrExpired(LocalDate.now().plusDays(days)); + } + + /* ---------- Photos ---------- */ + + @Transactional + public CarPhoto addPhoto(Long carId, String url, int sortOrder) { + Car car = findById(carId); + CarPhoto photo = CarPhoto.builder().car(car).url(url).sortOrder(sortOrder).build(); + return carPhotoRepository.save(photo); + } + + @Transactional + public void deletePhoto(Long photoId) { + carPhotoRepository.deleteById(photoId); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/CommissionService.java b/src/main/java/com/mvrent/carrental/service/CommissionService.java new file mode 100644 index 0000000..eefc2cd --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/CommissionService.java @@ -0,0 +1,43 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Car; +import com.mvrent.carrental.model.Vendor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * Resolves the organization commission percent for a car using a three-tier + * fallback: per-vehicle override → per-vendor rate → global default. + * Owned cars (no vendor) carry no commission. + */ +@Service +@RequiredArgsConstructor +public class CommissionService { + + private final ConfigService configService; + + /** Commission percent that applies to the given car (0 for owned cars). */ + public BigDecimal resolvePercent(Car car) { + if (car == null || car.getVendor() == null) { + return BigDecimal.ZERO; + } + if (car.getCommissionPercentOverride() != null) { + return car.getCommissionPercentOverride(); + } + Vendor vendor = car.getVendor(); + if (vendor.getCommissionPercent() != null) { + return vendor.getCommissionPercent(); + } + return configService.getSystemConfig().getDefaultCommissionPercent(); + } + + /** Commission amount on a gross figure for the given car. */ + public BigDecimal commissionAmount(Car car, BigDecimal gross) { + if (gross == null) return BigDecimal.ZERO; + return gross.multiply(resolvePercent(car)) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/ConfigService.java b/src/main/java/com/mvrent/carrental/service/ConfigService.java new file mode 100644 index 0000000..28ca601 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/ConfigService.java @@ -0,0 +1,103 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.CategoryRepository; +import com.mvrent.carrental.repository.OrganizationRepository; +import com.mvrent.carrental.repository.SystemConfigRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Central access to the Organization profile, SystemConfig singleton and the + * category masters. The singletons are created on first read so the rest of the + * app can always assume they exist. + */ +@Service +@RequiredArgsConstructor +public class ConfigService { + + private final OrganizationRepository organizationRepository; + private final SystemConfigRepository systemConfigRepository; + private final CategoryRepository categoryRepository; + + /* ---------- Organization ---------- */ + + @Transactional + public Organization getOrganization() { + return organizationRepository.findAll().stream().findFirst() + .orElseGet(() -> organizationRepository.save( + Organization.builder().name("MV Rent").currencyCode("INR").build())); + } + + @Transactional + public Organization saveOrganization(Organization form) { + Organization org = getOrganization(); + org.setName(form.getName()); + org.setLegalName(form.getLegalName()); + org.setLogoUrl(form.getLogoUrl()); + org.setAddress(form.getAddress()); + org.setGstNumber(form.getGstNumber()); + org.setContactEmail(form.getContactEmail()); + org.setPhone(form.getPhone()); + org.setCurrencyCode(form.getCurrencyCode()); + return organizationRepository.save(org); + } + + /* ---------- System config ---------- */ + + @Transactional + public SystemConfig getSystemConfig() { + return systemConfigRepository.findAll().stream().findFirst() + .orElseGet(() -> systemConfigRepository.save(SystemConfig.builder().build())); + } + + @Transactional + public SystemConfig saveSystemConfig(SystemConfig form) { + SystemConfig cfg = getSystemConfig(); + cfg.setCurrencyCode(form.getCurrencyCode()); + cfg.setDefaultTaxPercent(form.getDefaultTaxPercent()); + cfg.setDefaultCommissionPercent(form.getDefaultCommissionPercent()); + cfg.setDefaultDriverDailyCharge(form.getDefaultDriverDailyCharge()); + cfg.setInvoicePrefix(form.getInvoicePrefix()); + cfg.setSettlementCycle(form.getSettlementCycle()); + return systemConfigRepository.save(cfg); + } + + /* ---------- Categories ---------- */ + + public List allCategories() { + return categoryRepository.findAllByOrderByTypeAscNameAsc(); + } + + public List categoriesOfType(CategoryType type) { + return categoryRepository.findByTypeAndActiveTrueOrderByName(type); + } + + public Category findCategory(Long id) { + return id == null ? null : categoryRepository.findById(id).orElse(null); + } + + @Transactional + public Category addCategory(CategoryType type, String name, String description) { + if (categoryRepository.existsByTypeAndName(type, name)) { + throw new IllegalArgumentException(type + " category '" + name + "' already exists"); + } + return categoryRepository.save(Category.builder() + .type(type).name(name).description(description).active(true).build()); + } + + @Transactional + public void toggleCategory(Long id) { + Category c = categoryRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Category not found: " + id)); + c.setActive(!c.isActive()); + } + + @Transactional + public void deleteCategory(Long id) { + categoryRepository.deleteById(id); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/CustomUserDetailsService.java b/src/main/java/com/mvrent/carrental/service/CustomUserDetailsService.java new file mode 100644 index 0000000..88f62ac --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/CustomUserDetailsService.java @@ -0,0 +1,37 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.*; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("Username not found: " + username)); + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())); + user.getPermissions().forEach(p -> authorities.add(new SimpleGrantedAuthority(p.name()))); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + user.isEnabled(), + true, true, true, + authorities + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/DashboardService.java b/src/main/java/com/mvrent/carrental/service/DashboardService.java new file mode 100644 index 0000000..5d795a6 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/DashboardService.java @@ -0,0 +1,99 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** Aggregates the figures shown on the admin command-center dashboard. */ +@Service +@RequiredArgsConstructor +public class DashboardService { + + private static final DateTimeFormatter MONTH = DateTimeFormatter.ofPattern("MMM yy"); + + private final BookingRepository bookingRepository; + private final CarRepository carRepository; + private final DriverRepository driverRepository; + private final VendorRepository vendorRepository; + private final UserRepository userRepository; + private final CarService carService; + private final DriverService driverService; + + public DashboardData build() { + List all = bookingRepository.findAllByOrderByCreatedAtDesc(); + + BigDecimal totalRevenue = BigDecimal.ZERO; + BigDecimal ownedRevenue = BigDecimal.ZERO; + BigDecimal vendorRevenue = BigDecimal.ZERO; + long pending = 0, confirmed = 0, completed = 0, cancelled = 0; + + Map monthlyRev = new TreeMap<>(); + Map monthlyCnt = new TreeMap<>(); + + for (Booking b : all) { + BigDecimal total = nz(b.getTotalAmount()); + BigDecimal vehicle = b.getVehicleCharge() != null ? b.getVehicleCharge() : total; + totalRevenue = totalRevenue.add(total); + if (b.getRevenueOwner() == CarOwnership.VENDOR) vendorRevenue = vendorRevenue.add(vehicle); + else ownedRevenue = ownedRevenue.add(vehicle); + + switch (b.getStatus()) { + case PENDING -> pending++; + case CONFIRMED -> confirmed++; + case COMPLETED -> completed++; + case CANCELLED -> cancelled++; + } + + YearMonth ym = YearMonth.from(b.getPickupDate()); + monthlyRev.merge(ym, total, BigDecimal::add); + monthlyCnt.merge(ym, 1L, Long::sum); + } + + // Last 8 active months for the trend chart. + List months = new ArrayList<>(monthlyRev.keySet()); + if (months.size() > 8) months = months.subList(months.size() - 8, months.size()); + List monthLabels = new ArrayList<>(); + List monthRevenue = new ArrayList<>(); + List monthCount = new ArrayList<>(); + for (YearMonth ym : months) { + monthLabels.add(ym.format(MONTH)); + monthRevenue.add(monthlyRev.get(ym)); + monthCount.add(monthlyCnt.get(ym)); + } + + return new DashboardData( + carRepository.count(), + driverRepository.count(), + vendorRepository.count(), + all.size(), + userRepository.countByRole(Role.CUSTOMER), + totalRevenue, + pending, confirmed, completed, cancelled, + ownedRevenue, vendorRevenue, + monthLabels, monthRevenue, monthCount, + all.stream().limit(6).toList(), + carService.documentsExpiringWithin(30), + driverService.documentsExpiringWithin(30) + ); + } + + private static BigDecimal nz(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + public record DashboardData( + long totalCars, long totalDrivers, long totalVendors, long totalBookings, long totalCustomers, + BigDecimal totalRevenue, + long pending, long confirmed, long completed, long cancelled, + BigDecimal ownedRevenue, BigDecimal vendorRevenue, + List monthLabels, List monthRevenue, List monthCount, + List recentBookings, + List expiringCarDocs, List expiringDriverDocs + ) {} +} diff --git a/src/main/java/com/mvrent/carrental/service/DriverService.java b/src/main/java/com/mvrent/carrental/service/DriverService.java new file mode 100644 index 0000000..f778882 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/DriverService.java @@ -0,0 +1,137 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.BookingRepository; +import com.mvrent.carrental.repository.CategoryRepository; +import com.mvrent.carrental.repository.DriverDocumentRepository; +import com.mvrent.carrental.repository.DriverRepository; +import com.mvrent.carrental.repository.UserRepository; +import com.mvrent.carrental.repository.VendorRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DriverService { + + private final DriverRepository driverRepository; + private final DriverDocumentRepository driverDocumentRepository; + private final CategoryRepository categoryRepository; + private final BookingRepository bookingRepository; + private final VendorRepository vendorRepository; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public List findAll() { + return driverRepository.findAllByOrderByName(); + } + + public List findAvailable() { + return driverRepository.findByStatus(DriverStatus.AVAILABLE); + } + + public Driver findById(Long id) { + return driverRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Driver not found: " + id)); + } + + /** + * Persist a driver from the admin form. For an existing driver the scalar + * fields are copied onto the managed entity so its documents are preserved. + */ + @Transactional + public Driver saveFromForm(Driver form, Long categoryId, Long vendorId) { + Driver target = form.getId() != null ? findById(form.getId()) : new Driver(); + target.setName(form.getName()); + target.setPhone(form.getPhone()); + target.setLicenceNumber(form.getLicenceNumber()); + target.setLicenceExpiry(form.getLicenceExpiry()); + target.setYearsExperience(form.getYearsExperience()); + target.setCategory(categoryId == null ? null + : categoryRepository.findById(categoryId).orElse(null)); + target.setEmploymentType(form.getEmploymentType()); + target.setHourlyCharge(form.getHourlyCharge()); + target.setDailyCharge(form.getDailyCharge()); + target.setMonthlyCharge(form.getMonthlyCharge()); + target.setStatus(form.getStatus()); + target.setPhotoUrl(form.getPhotoUrl()); + // Vendor link only applies to vendor-supplied drivers. + if (form.getEmploymentType() == DriverEmploymentType.VENDOR && vendorId != null) { + target.setVendor(vendorRepository.findById(vendorId).orElse(null)); + } else { + target.setVendor(null); + } + return driverRepository.save(target); + } + + @Transactional + public void delete(Long id) { + driverRepository.deleteById(id); + } + + /** Set a driver's availability status (used by the inline activate/deactivate toggle). */ + @Transactional + public Driver updateStatus(Long id, DriverStatus status) { + Driver driver = findById(id); + driver.setStatus(status); + return driverRepository.save(driver); + } + + /** Create a /driver portal login linked to this driver record. */ + @Transactional + public User createPortalUser(Long driverId, String username, String email, String rawPassword) { + Driver driver = findById(driverId); + if (username == null || username.isBlank()) throw new IllegalArgumentException("Username is required"); + if (email == null || email.isBlank()) throw new IllegalArgumentException("Email is required"); + if (rawPassword == null || rawPassword.length() < 6) throw new IllegalArgumentException("Password must be at least 6 characters"); + if (userRepository.existsByUsername(username.trim())) throw new IllegalArgumentException("Username already taken"); + if (userRepository.existsByEmail(email.trim())) throw new IllegalArgumentException("Email already registered"); + User user = User.builder() + .username(username.trim()) + .email(email.trim()) + .password(passwordEncoder.encode(rawPassword)) + .fullName(driver.getName()) + .phone(driver.getPhone()) + .role(Role.DRIVER) + .driver(driver) + .enabled(true) + .build(); + return userRepository.save(user); + } + + /* ---------- Documents ---------- */ + + @Transactional + public DriverDocument addDocument(Long driverId, DriverDocumentType type, String fileUrl, + LocalDate issued, LocalDate expiry) { + Driver driver = findById(driverId); + DriverDocument doc = DriverDocument.builder() + .driver(driver).docType(type).fileUrl(fileUrl) + .issuedDate(issued).expiryDate(expiry).build(); + return driverDocumentRepository.save(doc); + } + + @Transactional + public void deleteDocument(Long docId) { + driverDocumentRepository.deleteById(docId); + } + + public List documentsExpiringWithin(int days) { + return driverDocumentRepository.findExpiringOrExpired(LocalDate.now().plusDays(days)); + } + + /* ---------- Trip history ---------- */ + + public List tripHistory(Driver driver) { + return bookingRepository.findByDriverOrderByCreatedAtDesc(driver); + } + + public long totalTrips(Driver driver) { + return bookingRepository.countByDriver(driver); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/ExcelService.java b/src/main/java/com/mvrent/carrental/service/ExcelService.java new file mode 100644 index 0000000..db70bbf --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/ExcelService.java @@ -0,0 +1,187 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.VendorSettlement; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.util.List; + +import com.mvrent.carrental.service.ReportService.DriverReportRow; +import com.mvrent.carrental.service.ReportService.ReportSummary; +import com.mvrent.carrental.service.ReportService.VendorReportRow; + +/** Builds .xlsx exports for vendor settlement statements. */ +@Service +public class ExcelService { + + public byte[] settlementWorkbook(VendorSettlement s, List trips) { + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream os = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("Settlement"); + + CellStyle bold = wb.createCellStyle(); + Font f = wb.createFont(); + f.setBold(true); + bold.setFont(f); + + int r = 0; + r = kv(sheet, r, "Vendor", s.getVendor().getName(), bold); + r = kv(sheet, r, "Period", s.getPeriodStart() + " to " + s.getPeriodEnd(), bold); + r = kv(sheet, r, "Status", String.valueOf(s.getStatus()), bold); + r = kv(sheet, r, "Trips", String.valueOf(s.getTripCount()), bold); + r = kv(sheet, r, "Gross Revenue", money(s.getGrossRevenue()), bold); + r = kv(sheet, r, "Commission", money(s.getCommissionAmount()), bold); + r = kv(sheet, r, "Vendor Driver Charges", money(s.getDriverCharges()), bold); + r = kv(sheet, r, "Net Payable", money(s.getNetPayable()), bold); + r++; + + // Trip line items + Row head = sheet.createRow(r++); + String[] cols = {"Booking", "Car", "Pickup", "Return", "Vehicle Charge", "Commission", "Driver Charge", "Total"}; + for (int i = 0; i < cols.length; i++) { + Cell c = head.createCell(i); + c.setCellValue(cols[i]); + c.setCellStyle(bold); + } + for (Booking b : trips) { + Row row = sheet.createRow(r++); + row.createCell(0).setCellValue("#" + b.getId()); + row.createCell(1).setCellValue(b.getCar().getMake() + " " + b.getCar().getModel()); + row.createCell(2).setCellValue(String.valueOf(b.getPickupDate())); + row.createCell(3).setCellValue(String.valueOf(b.getReturnDate())); + row.createCell(4).setCellValue(dbl(b.getVehicleCharge())); + row.createCell(5).setCellValue(dbl(b.getCommissionAmount())); + row.createCell(6).setCellValue(dbl(b.getDriverCharge())); + row.createCell(7).setCellValue(dbl(b.getTotalAmount())); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + + wb.write(os); + return os.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to build Excel: " + e.getMessage(), e); + } + } + + public byte[] bookingsWorkbook(List bookings, ReportSummary summary) { + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream os = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("Bookings"); + CellStyle bold = boldStyle(wb); + + String[] cols = {"ID", "Customer", "Car", "Driver", "Owner", "Pickup", "Return", + "Vehicle", "Driver Chg", "Tax", "Commission", "Total", "Status"}; + header(sheet, cols, bold); + int r = 1; + for (Booking b : bookings) { + Row row = sheet.createRow(r++); + row.createCell(0).setCellValue(b.getId()); + row.createCell(1).setCellValue(b.getUser().getFullName()); + row.createCell(2).setCellValue(b.getCar().getMake() + " " + b.getCar().getModel()); + row.createCell(3).setCellValue(b.isWithDriver() && b.getDriver() != null ? b.getDriver().getName() : "Self-drive"); + row.createCell(4).setCellValue(String.valueOf(b.getRevenueOwner())); + row.createCell(5).setCellValue(String.valueOf(b.getPickupDate())); + row.createCell(6).setCellValue(String.valueOf(b.getReturnDate())); + row.createCell(7).setCellValue(dbl(b.getVehicleCharge())); + row.createCell(8).setCellValue(dbl(b.getDriverCharge())); + row.createCell(9).setCellValue(dbl(b.getTaxAmount())); + row.createCell(10).setCellValue(dbl(b.getCommissionAmount())); + row.createCell(11).setCellValue(dbl(b.getTotalAmount())); + row.createCell(12).setCellValue(String.valueOf(b.getStatus())); + } + r++; + kv(sheet, r++, "Bookings", String.valueOf(summary.count()), bold); + kv(sheet, r++, "Gross (vehicle)", money(summary.gross()), bold); + kv(sheet, r++, "Driver charges", money(summary.driverCharges()), bold); + kv(sheet, r++, "Tax", money(summary.tax()), bold); + kv(sheet, r++, "Commission", money(summary.commission()), bold); + kv(sheet, r++, "Total", money(summary.total()), bold); + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + wb.write(os); + return os.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to build Excel: " + e.getMessage(), e); + } + } + + public byte[] vendorReportWorkbook(List rows) { + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream os = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("Vendor Revenue"); + CellStyle bold = boldStyle(wb); + String[] cols = {"Vendor", "Trips", "Gross", "Commission", "Net Payable"}; + header(sheet, cols, bold); + int r = 1; + for (VendorReportRow row : rows) { + Row x = sheet.createRow(r++); + x.createCell(0).setCellValue(row.vendor()); + x.createCell(1).setCellValue(row.trips()); + x.createCell(2).setCellValue(dbl(row.gross())); + x.createCell(3).setCellValue(dbl(row.commission())); + x.createCell(4).setCellValue(dbl(row.net())); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + wb.write(os); + return os.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to build Excel: " + e.getMessage(), e); + } + } + + public byte[] driverReportWorkbook(List rows) { + try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream os = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("Driver Report"); + CellStyle bold = boldStyle(wb); + String[] cols = {"Driver", "Category", "Trips", "Driver Charges"}; + header(sheet, cols, bold); + int r = 1; + for (DriverReportRow row : rows) { + Row x = sheet.createRow(r++); + x.createCell(0).setCellValue(row.driver()); + x.createCell(1).setCellValue(row.category()); + x.createCell(2).setCellValue(row.trips()); + x.createCell(3).setCellValue(dbl(row.charges())); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + wb.write(os); + return os.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to build Excel: " + e.getMessage(), e); + } + } + + private CellStyle boldStyle(Workbook wb) { + CellStyle bold = wb.createCellStyle(); + Font f = wb.createFont(); + f.setBold(true); + bold.setFont(f); + return bold; + } + + private void header(Sheet sheet, String[] cols, CellStyle bold) { + Row head = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) { + Cell c = head.createCell(i); + c.setCellValue(cols[i]); + c.setCellStyle(bold); + } + } + + private int kv(Sheet sheet, int r, String key, String val, CellStyle bold) { + Row row = sheet.createRow(r); + Cell k = row.createCell(0); + k.setCellValue(key); + k.setCellStyle(bold); + row.createCell(1).setCellValue(val); + return r + 1; + } + + private static String money(BigDecimal v) { + return v == null ? "0.00" : v.toPlainString(); + } + + private static double dbl(BigDecimal v) { + return v == null ? 0d : v.doubleValue(); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/FileStorageService.java b/src/main/java/com/mvrent/carrental/service/FileStorageService.java new file mode 100644 index 0000000..cf67a1c --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/FileStorageService.java @@ -0,0 +1,61 @@ +package com.mvrent.carrental.service; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * Stores uploaded files (documents, photos) under a configurable local directory + * and returns a public URL path (served by the resource handler in {@code WebConfig}). + */ +@Service +public class FileStorageService { + + private final Path root; + private final String urlPrefix; + + public FileStorageService(@Value("${app.storage.location}") String location, + @Value("${app.storage.url-prefix}") String urlPrefix) { + this.root = Paths.get(location).toAbsolutePath().normalize(); + this.urlPrefix = urlPrefix.endsWith("/") ? urlPrefix.substring(0, urlPrefix.length() - 1) : urlPrefix; + } + + @PostConstruct + void init() { + try { + Files.createDirectories(root); + } catch (IOException e) { + throw new IllegalStateException("Cannot create storage dir: " + root, e); + } + } + + /** Stores the file and returns its public URL path, or null if no file was uploaded. */ + public String store(MultipartFile file) { + if (file == null || file.isEmpty()) return null; + String original = file.getOriginalFilename() == null ? "" : file.getOriginalFilename(); + String ext = ""; + int dot = original.lastIndexOf('.'); + if (dot >= 0 && dot < original.length() - 1) { + ext = original.substring(dot).replaceAll("[^A-Za-z0-9.]", ""); + } + String name = UUID.randomUUID() + ext; + try { + Path target = root.resolve(name).normalize(); + if (!target.startsWith(root)) { + throw new IllegalArgumentException("Invalid file path"); + } + Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new RuntimeException("Failed to store file: " + e.getMessage(), e); + } + return urlPrefix + "/" + name; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/InspectionService.java b/src/main/java/com/mvrent/carrental/service/InspectionService.java new file mode 100644 index 0000000..4b177e0 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/InspectionService.java @@ -0,0 +1,44 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.InspectionRecord; +import com.mvrent.carrental.repository.BookingRepository; +import com.mvrent.carrental.repository.InspectionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InspectionService { + + private final InspectionRepository inspectionRepository; + private final CarService carService; + private final BookingRepository bookingRepository; + + public List findAll() { + return inspectionRepository.findAllByOrderByInspectionDateDescIdDesc(); + } + + public List findByCar(Long carId) { + return inspectionRepository.findByCarIdOrderByInspectionDateDescIdDesc(carId); + } + + public InspectionRecord findById(Long id) { + return inspectionRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Inspection not found: " + id)); + } + + @Transactional + public InspectionRecord save(InspectionRecord record, Long carId, Long bookingId) { + record.setCar(carService.findById(carId)); + record.setBooking(bookingId != null ? bookingRepository.findById(bookingId).orElse(null) : null); + return inspectionRepository.save(record); + } + + @Transactional + public void delete(Long id) { + inspectionRepository.deleteById(id); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/MailDispatcher.java b/src/main/java/com/mvrent/carrental/service/MailDispatcher.java new file mode 100644 index 0000000..0a32c31 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/MailDispatcher.java @@ -0,0 +1,68 @@ +package com.mvrent.carrental.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +/** + * Low-level SMTP sender. Separate bean so {@code @Async} actually applies when + * called from {@link NotificationService} (self-invocation would bypass the proxy). + * Sends only plain strings, so it is safe to run off the request thread. + */ +@Component +@Slf4j +public class MailDispatcher { + + private final ObjectProvider mailSenderProvider; + + @Value("${app.mail.enabled:false}") + private boolean mailEnabled; + @Value("${app.mail.from:no-reply@mvrent.local}") + private String from; + + public MailDispatcher(ObjectProvider mailSenderProvider) { + this.mailSenderProvider = mailSenderProvider; + } + + public boolean isReady() { + return mailEnabled && mailSenderProvider.getIfAvailable() != null; + } + + /** Fire-and-forget send; never throws. */ + @Async + public void send(String to, String subject, String body) { + JavaMailSender sender = mailSenderProvider.getIfAvailable(); + if (!mailEnabled || sender == null) { + log.info("[email skipped — not configured] to={} subject={}", to, subject); + return; + } + try { + sender.send(message(to, subject, body)); + log.info("Sent email to {}: {}", to, subject); + } catch (Exception ex) { + log.warn("Email send failed to {}: {}", to, ex.getMessage()); + } + } + + /** Synchronous send used by the test button; throws so the UI sees the error. */ + public void sendNow(String to, String subject, String body) { + if (!isReady()) { + throw new IllegalStateException("Email is not configured (set spring.mail.host and app.mail.enabled=true)"); + } + mailSenderProvider.getObject().send(message(to, subject, body)); + log.info("Sent email to {}: {}", to, subject); + } + + private SimpleMailMessage message(String to, String subject, String body) { + SimpleMailMessage msg = new SimpleMailMessage(); + msg.setFrom(from); + msg.setTo(to); + msg.setSubject(subject); + msg.setText(body); + return msg; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/MaintenanceService.java b/src/main/java/com/mvrent/carrental/service/MaintenanceService.java new file mode 100644 index 0000000..1f5796a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/MaintenanceService.java @@ -0,0 +1,64 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.MaintenanceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MaintenanceService { + + private static final List OPEN = + List.of(MaintenanceStatus.SCHEDULED, MaintenanceStatus.IN_PROGRESS); + + private final MaintenanceRepository maintenanceRepository; + private final CarService carService; + + public List findAll() { + return maintenanceRepository.findAllByOrderByServiceDateDescIdDesc(); + } + + public List findByCar(Long carId) { + return maintenanceRepository.findByCarIdOrderByServiceDateDescIdDesc(carId); + } + + public MaintenanceRecord findById(Long id) { + return maintenanceRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Maintenance record not found: " + id)); + } + + @Transactional + public MaintenanceRecord save(MaintenanceRecord record, Long carId) { + Car car = carService.findById(carId); + record.setCar(car); + MaintenanceRecord saved = maintenanceRepository.save(record); + syncCarStatus(car); + return saved; + } + + @Transactional + public void delete(Long id) { + MaintenanceRecord r = findById(id); + Car car = r.getCar(); + maintenanceRepository.delete(r); + if (car != null) syncCarStatus(car); + } + + /** + * A car with any open (scheduled / in-progress) job is forced to MAINTENANCE; + * once all jobs are closed it returns to ACTIVE (unless RETIRED). + */ + private void syncCarStatus(Car car) { + if (car.getStatus() == CarStatus.RETIRED) return; + boolean hasOpen = maintenanceRepository.existsByCarIdAndStatusIn(car.getId(), OPEN); + CarStatus target = hasOpen ? CarStatus.MAINTENANCE : CarStatus.ACTIVE; + if (car.getStatus() != target) { + car.setStatus(target); + carService.save(car); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/service/NotificationService.java b/src/main/java/com/mvrent/carrental/service/NotificationService.java new file mode 100644 index 0000000..ac8f79b --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/NotificationService.java @@ -0,0 +1,64 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Booking; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Composes transactional notifications and hands them to {@link MailDispatcher} + * for async, fail-safe delivery. Bodies are built by the caller (inside its + * transaction) so no lazy associations are touched off the request thread. + */ +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final MailDispatcher mailDispatcher; + + @Value("${app.mail.admin:}") + private String adminEmail; + @Value("${app.currency.symbol:₹}") + private String currency; + + public boolean isReady() { return mailDispatcher.isReady(); } + + public String adminEmail() { return adminEmail; } + + /* ---------- Booking events (composed in-tx, sent async) ---------- */ + + public void sendBookingConfirmation(Booking b) { + if (b.getUser() == null || b.getUser().getEmail() == null) return; + String car = b.getCar() != null ? b.getCar().getMake() + " " + b.getCar().getModel() : "your vehicle"; + String subject = "Booking confirmed — #" + b.getId(); + String body = "Hi " + safeName(b) + ",\n\n" + + "Your booking #" + b.getId() + " is confirmed.\n\n" + + "Car: " + car + "\n" + + "Pickup: " + b.getPickupDate() + " from " + b.getPickupLocation() + "\n" + + "Return: " + b.getReturnDate() + " at " + b.getDropLocation() + "\n" + + (b.isWithDriver() ? "Driver: included\n" : "") + + "Total: " + currency + b.getTotalAmount() + "\n\n" + + "Thank you for choosing MV Rent."; + mailDispatcher.send(b.getUser().getEmail(), subject, body); + } + + public void sendBookingStatusUpdate(Booking b) { + if (b.getUser() == null || b.getUser().getEmail() == null) return; + String subject = "Booking #" + b.getId() + " — " + b.getStatus(); + String body = "Hi " + safeName(b) + ",\n\n" + + "The status of your booking #" + b.getId() + " is now: " + b.getStatus() + ".\n\n" + + "— MV Rent"; + mailDispatcher.send(b.getUser().getEmail(), subject, body); + } + + /** Synchronous test send used by the Settings screen — throws on failure. */ + public void sendTest(String to) { + mailDispatcher.sendNow(to, "MV Rent — test email", + "This is a test email from MV Rent. Your SMTP settings are working."); + } + + private static String safeName(Booking b) { + String n = b.getUser() != null ? b.getUser().getFullName() : null; + return (n != null && !n.isBlank()) ? n : "there"; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/OAuth2LoginService.java b/src/main/java/com/mvrent/carrental/service/OAuth2LoginService.java new file mode 100644 index 0000000..e9db634 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/OAuth2LoginService.java @@ -0,0 +1,68 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Role; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * Resolves a Google (OAuth2) sign-in to a local {@link User}. First-time Google + * users are provisioned as CUSTOMER accounts; returning users are matched by email. + * Admins are intentionally NOT created here — admin access stays on /admin/login. + */ +@Service +@RequiredArgsConstructor +public class OAuth2LoginService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Override + public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(request); + + String email = oAuth2User.getAttribute("email"); + String name = oAuth2User.getAttribute("name"); + if (email == null || email.isBlank()) { + throw new OAuth2AuthenticationException("Google account has no email"); + } + + User user = userRepository.findByEmail(email).orElseGet(() -> provision(email, name)); + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())), + oAuth2User.getAttributes(), + "email" + ); + } + + private User provision(String email, String name) { + String base = email.substring(0, email.indexOf('@')).replaceAll("[^a-zA-Z0-9._-]", ""); + String username = base; + int i = 1; + while (userRepository.existsByUsername(username)) { + username = base + i++; + } + User user = User.builder() + .username(username) + .email(email) + .fullName(name != null && !name.isBlank() ? name : base) + // random un-guessable password — these users sign in via Google only + .password(passwordEncoder.encode(UUID.randomUUID().toString())) + .role(Role.CUSTOMER) + .enabled(true) + .build(); + return userRepository.save(user); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/PaymentService.java b/src/main/java/com/mvrent/carrental/service/PaymentService.java new file mode 100644 index 0000000..eafb77e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/PaymentService.java @@ -0,0 +1,68 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.Payment; +import com.mvrent.carrental.model.PaymentMethod; +import com.mvrent.carrental.repository.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final BookingService bookingService; + + public List forBooking(Long bookingId) { + return paymentRepository.findByBookingIdOrderByPaidAtDescIdDesc(bookingId); + } + + public BigDecimal totalPaid(Long bookingId) { + BigDecimal sum = paymentRepository.sumForBooking(bookingId); + return sum == null ? BigDecimal.ZERO : sum; + } + + /** bookingId -> total paid, for the whole bookings list in one query. */ + public Map paidByBooking() { + Map map = new HashMap<>(); + for (Object[] row : paymentRepository.sumGroupedByBooking()) { + map.put((Long) row[0], (BigDecimal) row[1]); + } + return map; + } + + @Transactional + public Payment record(Long bookingId, BigDecimal amount, PaymentMethod method, + String reference, LocalDate paidAt, String notes) { + if (amount == null || amount.signum() <= 0) { + throw new IllegalArgumentException("Amount must be greater than zero"); + } + Booking booking = bookingService.findById(bookingId); + String user = SecurityContextHolder.getContext().getAuthentication() != null + ? SecurityContextHolder.getContext().getAuthentication().getName() : null; + Payment payment = Payment.builder() + .booking(booking) + .amount(amount) + .method(method != null ? method : PaymentMethod.CASH) + .reference(reference) + .paidAt(paidAt != null ? paidAt : LocalDate.now()) + .notes(notes) + .recordedBy(user) + .build(); + return paymentRepository.save(payment); + } + + @Transactional + public void delete(Long id) { + paymentRepository.deleteById(id); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/PdfService.java b/src/main/java/com/mvrent/carrental/service/PdfService.java new file mode 100644 index 0000000..2b09171 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/PdfService.java @@ -0,0 +1,88 @@ +package com.mvrent.carrental.service; + +import com.lowagie.text.pdf.BaseFont; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.xhtmlrenderer.pdf.ITextRenderer; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.Map; + +/** + * Renders a Thymeleaf template (strict XHTML under templates/pdf/) to a PDF + * byte array via Flying Saucer. A configurable TTF font (containing the ₹ glyph) + * is embedded so currency renders correctly. + */ +@Service +@Slf4j +public class PdfService { + + private final SpringTemplateEngine templateEngine; + private final ResourceLoader resourceLoader; + private final String fontLocation; + private final String currencySymbol; + + /** Absolute path to a usable font file, or null if none configured/available. */ + private String fontPath; + + public PdfService(SpringTemplateEngine templateEngine, + ResourceLoader resourceLoader, + @Value("${app.pdf.font:}") String fontLocation, + @Value("${app.currency.symbol}") String currencySymbol) { + this.templateEngine = templateEngine; + this.resourceLoader = resourceLoader; + this.fontLocation = fontLocation; + this.currencySymbol = currencySymbol; + } + + @PostConstruct + void resolveFont() { + if (fontLocation == null || fontLocation.isBlank()) return; + try { + Resource res = resourceLoader.getResource(fontLocation); + if (!res.exists()) { + log.warn("PDF font not found at {} — currency will use a fallback glyph", fontLocation); + return; + } + // Copy to a temp file so it works whether running from disk or inside a jar. + File tmp = File.createTempFile("pdf-font-", ".ttf"); + tmp.deleteOnExit(); + try (InputStream in = res.getInputStream()) { + Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + this.fontPath = tmp.getAbsolutePath(); + log.info("PDF font registered from {}", fontLocation); + } catch (Exception e) { + log.warn("Failed to load PDF font {}: {}", fontLocation, e.getMessage()); + } + } + + public byte[] render(String template, Map vars) { + Context ctx = new Context(); + ctx.setVariables(vars); + ctx.setVariable("currencySymbol", currencySymbol); + String html = templateEngine.process(template, ctx); + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + ITextRenderer renderer = new ITextRenderer(); + if (fontPath != null) { + renderer.getFontResolver().addFont(fontPath, BaseFont.IDENTITY_H, BaseFont.EMBEDDED); + } + renderer.setDocumentFromString(html); + renderer.layout(); + renderer.createPDF(os); + return os.toByteArray(); + } catch (Exception e) { + throw new RuntimeException("Failed to render PDF (" + template + "): " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/mvrent/carrental/service/ReportService.java b/src/main/java/com/mvrent/carrental/service/ReportService.java new file mode 100644 index 0000000..b70a787 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/ReportService.java @@ -0,0 +1,98 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.BookingRepository; +import jakarta.persistence.criteria.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final BookingRepository bookingRepository; + private final VendorService vendorService; + private final DriverService driverService; + + /* ---------- Bookings report ---------- */ + + public List bookings(BookingFilter f) { + return bookingRepository.findAll(spec(f), Sort.by(Sort.Direction.DESC, "pickupDate")); + } + + private Specification spec(BookingFilter f) { + return (root, query, cb) -> { + List ps = new ArrayList<>(); + if (f.from() != null) ps.add(cb.greaterThanOrEqualTo(root.get("pickupDate"), f.from())); + if (f.to() != null) ps.add(cb.lessThanOrEqualTo(root.get("pickupDate"), f.to())); + if (f.carId() != null) ps.add(cb.equal(root.get("car").get("id"), f.carId())); + if (f.driverId() != null) ps.add(cb.equal(root.get("driver").get("id"), f.driverId())); + if (f.vendorId() != null) ps.add(cb.equal(root.get("vendor").get("id"), f.vendorId())); + if (f.ownership() != null) ps.add(cb.equal(root.get("revenueOwner"), f.ownership())); + if (f.status() != null) ps.add(cb.equal(root.get("status"), f.status())); + if (f.customerCategoryId() != null) { + ps.add(cb.equal(root.get("user").get("customerCategory").get("id"), f.customerCategoryId())); + } + return cb.and(ps.toArray(new Predicate[0])); + }; + } + + public ReportSummary summarize(List bookings) { + BigDecimal gross = BigDecimal.ZERO, driver = BigDecimal.ZERO, + tax = BigDecimal.ZERO, commission = BigDecimal.ZERO, total = BigDecimal.ZERO; + for (Booking b : bookings) { + gross = gross.add(nz(b.getVehicleCharge())); + driver = driver.add(nz(b.getDriverCharge())); + tax = tax.add(nz(b.getTaxAmount())); + commission = commission.add(nz(b.getCommissionAmount())); + total = total.add(nz(b.getTotalAmount())); + } + return new ReportSummary(bookings.size(), gross, driver, tax, commission, total); + } + + /* ---------- Vendor-wise report ---------- */ + + public List vendorReport() { + List rows = new ArrayList<>(); + for (Vendor v : vendorService.findAll()) { + VendorRevenue r = vendorService.revenue(v.getId()); + rows.add(new VendorReportRow(v.getName(), r.trips(), r.gross(), r.commission(), r.net())); + } + return rows; + } + + /* ---------- Driver-wise report ---------- */ + + public List driverReport() { + List rows = new ArrayList<>(); + for (Driver d : driverService.findAll()) { + List trips = driverService.tripHistory(d); + BigDecimal charges = BigDecimal.ZERO; + for (Booking b : trips) charges = charges.add(nz(b.getDriverCharge())); + rows.add(new DriverReportRow(d.getName(), + d.getCategory() != null ? d.getCategory().getName() : "—", + trips.size(), charges)); + } + return rows; + } + + private static BigDecimal nz(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + /* ---------- Row records ---------- */ + + public record ReportSummary(long count, BigDecimal gross, BigDecimal driverCharges, + BigDecimal tax, BigDecimal commission, BigDecimal total) {} + + public record VendorReportRow(String vendor, long trips, BigDecimal gross, + BigDecimal commission, BigDecimal net) {} + + public record DriverReportRow(String driver, String category, long trips, BigDecimal charges) {} +} diff --git a/src/main/java/com/mvrent/carrental/service/ReviewService.java b/src/main/java/com/mvrent/carrental/service/ReviewService.java new file mode 100644 index 0000000..e6fd09f --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/ReviewService.java @@ -0,0 +1,77 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Booking; +import com.mvrent.carrental.model.Review; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.repository.BookingRepository; +import com.mvrent.carrental.repository.ReviewRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final CarService carService; + private final BookingRepository bookingRepository; + + public List findAll() { + return reviewRepository.findAllByOrderByCreatedAtDesc(); + } + + public List approvedForCar(Long carId) { + return reviewRepository.findByCarIdAndApprovedTrueOrderByCreatedAtDesc(carId); + } + + /** Average rating rounded to one decimal, or 0 when none. */ + public double averageForCar(Long carId) { + Double avg = reviewRepository.averageForCar(carId); + return avg == null ? 0 : Math.round(avg * 10.0) / 10.0; + } + + public long countForCar(Long carId) { + return reviewRepository.countApprovedForCar(carId); + } + + public Review findById(Long id) { + return reviewRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Review not found: " + id)); + } + + @Transactional + public Review addReview(User user, Long carId, Long bookingId, int rating, String comment) { + if (rating < 1 || rating > 5) throw new IllegalArgumentException("Rating must be between 1 and 5"); + Booking booking = null; + if (bookingId != null) { + booking = bookingRepository.findById(bookingId).orElse(null); + if (booking != null && reviewRepository.existsByBookingId(bookingId)) { + throw new IllegalArgumentException("You have already reviewed this trip"); + } + } + Review review = Review.builder() + .car(carService.findById(carId)) + .user(user) + .booking(booking) + .rating(rating) + .comment(comment != null && comment.isBlank() ? null : comment) + .approved(true) + .build(); + return reviewRepository.save(review); + } + + @Transactional + public void setApproved(Long id, boolean approved) { + Review r = findById(id); + r.setApproved(approved); + reviewRepository.save(r); + } + + @Transactional + public void delete(Long id) { + reviewRepository.deleteById(id); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/SettlementService.java b/src/main/java/com/mvrent/carrental/service/SettlementService.java new file mode 100644 index 0000000..4db4d48 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/SettlementService.java @@ -0,0 +1,90 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.BookingRepository; +import com.mvrent.carrental.repository.VendorRepository; +import com.mvrent.carrental.repository.VendorSettlementRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SettlementService { + + /** Only realised revenue (confirmed/completed) is settled. */ + static final List SETTLED_STATUSES = + List.of(BookingStatus.CONFIRMED, BookingStatus.COMPLETED); + + private final VendorSettlementRepository settlementRepository; + private final VendorRepository vendorRepository; + private final BookingRepository bookingRepository; + + public List findAll() { + return settlementRepository.findAllByOrderByGeneratedAtDesc(); + } + + public List findByVendor(Long vendorId) { + return settlementRepository.findByVendorIdOrderByGeneratedAtDesc(vendorId); + } + + public VendorSettlement findById(Long id) { + return settlementRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Settlement not found: " + id)); + } + + /** Bookings included in a settlement (its vendor, period and settled statuses). */ + public List tripsFor(VendorSettlement s) { + return bookingRepository.findByCarVendorIdAndStatusInAndPickupDateBetweenOrderByPickupDate( + s.getVendor().getId(), SETTLED_STATUSES, s.getPeriodStart(), s.getPeriodEnd()); + } + + @Transactional + public VendorSettlement generate(Long vendorId, LocalDate start, LocalDate end) { + if (start == null || end == null || end.isBefore(start)) { + throw new IllegalArgumentException("Invalid settlement period"); + } + Vendor vendor = vendorRepository.findById(vendorId) + .orElseThrow(() -> new IllegalArgumentException("Vendor not found: " + vendorId)); + + List trips = bookingRepository + .findByCarVendorIdAndStatusInAndPickupDateBetweenOrderByPickupDate( + vendorId, SETTLED_STATUSES, start, end); + + BigDecimal gross = BigDecimal.ZERO; + BigDecimal commission = BigDecimal.ZERO; + BigDecimal driverCharges = BigDecimal.ZERO; + for (Booking b : trips) { + gross = gross.add(nz(b.getVehicleCharge())); + commission = commission.add(nz(b.getCommissionAmount())); + // Driver charge is owed to the vendor only when the driver is vendor-supplied. + if (b.getDriver() != null && b.getDriver().getVendor() != null + && b.getDriver().getVendor().getId().equals(vendorId)) { + driverCharges = driverCharges.add(nz(b.getDriverCharge())); + } + } + BigDecimal net = gross.subtract(commission).add(driverCharges); + + VendorSettlement settlement = VendorSettlement.builder() + .vendor(vendor).periodStart(start).periodEnd(end) + .tripCount(trips.size()) + .grossRevenue(gross).commissionAmount(commission) + .driverCharges(driverCharges).netPayable(net) + .status(SettlementStatus.DRAFT) + .build(); + return settlementRepository.save(settlement); + } + + @Transactional + public void updateStatus(Long id, SettlementStatus status) { + findById(id).setStatus(status); + } + + private static BigDecimal nz(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } +} diff --git a/src/main/java/com/mvrent/carrental/service/SmsService.java b/src/main/java/com/mvrent/carrental/service/SmsService.java new file mode 100644 index 0000000..e6c268a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/SmsService.java @@ -0,0 +1,39 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Booking; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +/** + * SMS notifications. Pluggable: today it logs the message (and no-ops when + * disabled). Wire a provider (Twilio, MSG91, …) inside {@link #dispatch} to go live. + */ +@Service +@Slf4j +public class SmsService { + + @Value("${app.sms.enabled:false}") + private boolean smsEnabled; + + public boolean isEnabled() { return smsEnabled; } + + @Async + public void sendBookingConfirmation(Booking b) { + if (b.getUser() == null) return; + String phone = b.getUser().getPhone(); + if (phone == null || phone.isBlank()) return; + dispatch(phone, "MV Rent: booking #" + b.getId() + " confirmed for " + + b.getPickupDate() + ". Total " + b.getTotalAmount() + "."); + } + + private void dispatch(String phone, String message) { + if (!smsEnabled) { + log.info("[sms skipped — disabled] to={} msg={}", phone, message); + return; + } + // TODO: integrate an SMS gateway here (Twilio/MSG91/etc.). + log.info("[sms] to={} msg={}", phone, message); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/TelematicsService.java b/src/main/java/com/mvrent/carrental/service/TelematicsService.java new file mode 100644 index 0000000..62bea29 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/TelematicsService.java @@ -0,0 +1,50 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.VehicleLocation; +import com.mvrent.carrental.repository.VehicleLocationRepository; +import com.mvrent.carrental.web.TelematicsPing; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TelematicsService { + + private final VehicleLocationRepository locationRepository; + private final CarService carService; + + @Value("${app.telematics.api-key:}") + private String apiKey; + + public boolean apiKeyValid(String provided) { + // When no key is configured, accept everything (dev convenience). + return apiKey == null || apiKey.isBlank() || apiKey.equals(provided); + } + + public List findAll() { + return locationRepository.findAll(); + } + + /** Upsert the latest position for a car. */ + @Transactional + public VehicleLocation ingest(TelematicsPing p) { + if (p.carId() == null) throw new IllegalArgumentException("carId is required"); + if (p.lat() == null || p.lng() == null) throw new IllegalArgumentException("lat and lng are required"); + + VehicleLocation loc = locationRepository.findByCarId(p.carId()) + .orElseGet(() -> VehicleLocation.builder().car(carService.findById(p.carId())).build()); + loc.setLatitude(p.lat()); + loc.setLongitude(p.lng()); + loc.setSpeedKph(p.speed()); + loc.setHeading(p.heading()); + if (p.odometer() != null) loc.setOdometer(p.odometer()); + loc.setIgnitionOn(p.ignition()); + loc.setUpdatedAt(LocalDateTime.now()); + return locationRepository.save(loc); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/UserService.java b/src/main/java/com/mvrent/carrental/service/UserService.java new file mode 100644 index 0000000..00561ba --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/UserService.java @@ -0,0 +1,175 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.Role; +import com.mvrent.carrental.model.User; +import com.mvrent.carrental.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public User register(User user, String rawPassword) { + if (userRepository.existsByUsername(user.getUsername())) { + throw new IllegalArgumentException("Username already taken"); + } + if (userRepository.existsByEmail(user.getEmail())) { + throw new IllegalArgumentException("Email already registered"); + } + user.setPassword(passwordEncoder.encode(rawPassword)); + if (user.getRole() == null) user.setRole(Role.CUSTOMER); + user.setEnabled(true); + return userRepository.save(user); + } + + public User findByUsername(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + username)); + } + + public User findById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Customer not found: " + id)); + } + + public java.util.List findCustomers() { + return userRepository.findByRoleOrderByFullName(Role.CUSTOMER); + } + + /* ---------- Staff / user management (ADMIN + STAFF) ---------- */ + + public java.util.List findStaff() { + return userRepository.findByRoleInOrderByFullName(java.util.List.of(Role.ADMIN, Role.STAFF)); + } + + @org.springframework.transaction.annotation.Transactional + public User createStaff(com.mvrent.carrental.web.StaffForm f) { + if (f.username() == null || f.username().isBlank()) throw new IllegalArgumentException("Username is required"); + if (f.email() == null || f.email().isBlank()) throw new IllegalArgumentException("Email is required"); + if (f.password() == null || f.password().length() < 6) throw new IllegalArgumentException("Password must be at least 6 characters"); + if (userRepository.existsByUsername(f.username().trim())) throw new IllegalArgumentException("Username already taken"); + if (userRepository.existsByEmail(f.email().trim())) throw new IllegalArgumentException("Email already registered"); + + Role role = (f.role() == Role.ADMIN) ? Role.ADMIN : Role.STAFF; + User u = new User(); + u.setUsername(f.username().trim()); + u.setFullName(f.fullName() != null && !f.fullName().isBlank() ? f.fullName().trim() : f.username().trim()); + u.setEmail(f.email().trim()); + u.setPhone(f.phone()); + u.setRole(role); + u.setEnabled(f.enabled() == null || f.enabled()); + u.setPassword(passwordEncoder.encode(f.password())); + u.setPermissions(resolvePermissions(role, f.permissions())); + return userRepository.save(u); + } + + @org.springframework.transaction.annotation.Transactional + public User updateStaff(com.mvrent.carrental.web.StaffForm f) { + User u = findById(f.id()); + requireStaff(u); + if (f.email() != null && !f.email().isBlank()) { + userRepository.findByEmail(f.email().trim()) + .filter(other -> !other.getId().equals(u.getId())) + .ifPresent(other -> { throw new IllegalArgumentException("Email already registered"); }); + u.setEmail(f.email().trim()); + } + if (f.fullName() != null && !f.fullName().isBlank()) u.setFullName(f.fullName().trim()); + u.setPhone(f.phone()); + Role role = (f.role() == Role.ADMIN) ? Role.ADMIN : Role.STAFF; + u.setRole(role); + if (f.enabled() != null) u.setEnabled(f.enabled()); + u.setPermissions(resolvePermissions(role, f.permissions())); + return userRepository.save(u); + } + + @org.springframework.transaction.annotation.Transactional + public void setEnabled(Long id, boolean enabled) { + User u = findById(id); + requireStaff(u); + if (!enabled) guardLastAdmin(u); + u.setEnabled(enabled); + userRepository.save(u); + } + + @org.springframework.transaction.annotation.Transactional + public void resetPassword(Long id, String newPassword) { + if (newPassword == null || newPassword.length() < 6) throw new IllegalArgumentException("Password must be at least 6 characters"); + User u = findById(id); + requireStaff(u); + u.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(u); + } + + @org.springframework.transaction.annotation.Transactional + public void deleteStaff(Long id) { + User u = findById(id); + requireStaff(u); + guardLastAdmin(u); + userRepository.delete(u); + } + + private void requireStaff(User u) { + if (u.getRole() != Role.ADMIN && u.getRole() != Role.STAFF) { + throw new IllegalArgumentException("Not a staff/admin account"); + } + } + + /** Don't allow removing/disabling the last enabled admin. */ + private void guardLastAdmin(User u) { + if (u.getRole() == Role.ADMIN && u.isEnabled() + && userRepository.countByRoleAndEnabledTrue(Role.ADMIN) <= 1) { + throw new IllegalArgumentException("Cannot remove the last active administrator"); + } + } + + private java.util.Set resolvePermissions( + Role role, java.util.Set requested) { + // Admins implicitly hold every permission; staff get exactly what's granted. + if (role == Role.ADMIN) return java.util.EnumSet.allOf(com.mvrent.carrental.model.Permission.class); + return (requested == null || requested.isEmpty()) + ? java.util.EnumSet.noneOf(com.mvrent.carrental.model.Permission.class) + : java.util.EnumSet.copyOf(requested); + } + + /** Create a walk-in / counter customer record on the fly (admin booking). */ + @org.springframework.transaction.annotation.Transactional + public User createWalkInCustomer(String fullName, String phone, String email) { + User u = new User(); + String base = (fullName == null ? "guest" : fullName.trim().toLowerCase().replaceAll("[^a-z0-9]+", "")).trim(); + if (base.isEmpty()) base = "guest"; + String username = base; + int n = 1; + while (userRepository.existsByUsername(username)) username = base + (++n); + u.setUsername(username); + u.setFullName(fullName == null || fullName.isBlank() ? "Walk-in Customer" : fullName.trim()); + if (email != null && !email.isBlank() && !userRepository.existsByEmail(email.trim())) { + u.setEmail(email.trim()); + } else { + u.setEmail(username + "@walkin.local"); + } + u.setPhone(phone); + u.setRole(Role.CUSTOMER); + u.setEnabled(true); + u.setPassword(passwordEncoder.encode(java.util.UUID.randomUUID().toString())); + return userRepository.save(u); + } + + /** Change the user's password after verifying the current one. */ + @org.springframework.transaction.annotation.Transactional + public void changePassword(String username, String currentPassword, String newPassword) { + User user = findByUsername(username); + if (currentPassword == null || !passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new IllegalArgumentException("Current password is incorrect"); + } + if (newPassword == null || newPassword.length() < 6) { + throw new IllegalArgumentException("New password must be at least 6 characters"); + } + user.setPassword(passwordEncoder.encode(newPassword)); + userRepository.save(user); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/VendorRevenue.java b/src/main/java/com/mvrent/carrental/service/VendorRevenue.java new file mode 100644 index 0000000..3f04ce2 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/VendorRevenue.java @@ -0,0 +1,10 @@ +package com.mvrent.carrental.service; + +import java.math.BigDecimal; + +/** Aggregated earnings view for a vendor over a set of trips. */ +public record VendorRevenue(long trips, BigDecimal gross, BigDecimal commission, BigDecimal net) { + public static VendorRevenue empty() { + return new VendorRevenue(0, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); + } +} diff --git a/src/main/java/com/mvrent/carrental/service/VendorService.java b/src/main/java/com/mvrent/carrental/service/VendorService.java new file mode 100644 index 0000000..7a1fd73 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/service/VendorService.java @@ -0,0 +1,101 @@ +package com.mvrent.carrental.service; + +import com.mvrent.carrental.model.*; +import com.mvrent.carrental.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class VendorService { + + private final VendorRepository vendorRepository; + private final UserRepository userRepository; + private final CarRepository carRepository; + private final DriverRepository driverRepository; + private final BookingRepository bookingRepository; + private final CommissionService commissionService; + private final PasswordEncoder passwordEncoder; + + public List findAll() { + return vendorRepository.findAllByOrderByName(); + } + + public List findActive() { + return vendorRepository.findByActiveTrueOrderByName(); + } + + public Vendor findById(Long id) { + return vendorRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("Vendor not found: " + id)); + } + + @Transactional + public Vendor save(Vendor vendor) { + return vendorRepository.save(vendor); + } + + @Transactional + public void delete(Long id) { + vendorRepository.deleteById(id); + } + + /* ---------- Portal login ---------- */ + + @Transactional + public User createPortalUser(Long vendorId, String username, String email, String rawPassword) { + Vendor vendor = findById(vendorId); + if (userRepository.existsByUsername(username)) { + throw new IllegalArgumentException("Username already taken"); + } + if (userRepository.existsByEmail(email)) { + throw new IllegalArgumentException("Email already registered"); + } + User user = User.builder() + .username(username) + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .fullName(vendor.getName() + " (Vendor)") + .role(Role.VENDOR) + .vendor(vendor) + .enabled(true) + .build(); + return userRepository.save(user); + } + + /* ---------- Scoped views ---------- */ + + public List cars(Long vendorId) { + return carRepository.findByVendorIdOrderByMake(vendorId); + } + + public List drivers(Long vendorId) { + return driverRepository.findByVendorIdOrderByName(vendorId); + } + + public List trips(Long vendorId) { + return bookingRepository.findByCarVendorIdOrderByCreatedAtDesc(vendorId); + } + + /** Gross revenue, commission deducted and net payable across a vendor's trips. */ + public VendorRevenue revenue(Long vendorId) { + List trips = trips(vendorId); + BigDecimal gross = BigDecimal.ZERO; + BigDecimal commission = BigDecimal.ZERO; + for (Booking b : trips) { + // Gross = rental (vehicle) revenue; fall back to total for pre-breakdown bookings. + BigDecimal base = b.getVehicleCharge() != null ? b.getVehicleCharge() + : (b.getTotalAmount() == null ? BigDecimal.ZERO : b.getTotalAmount()); + BigDecimal comm = b.getCommissionAmount() != null ? b.getCommissionAmount() + : commissionService.commissionAmount(b.getCar(), base); + gross = gross.add(base); + commission = commission.add(comm); + } + return new VendorRevenue(trips.size(), gross, commission, gross.subtract(commission)); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/AdminBookingForm.java b/src/main/java/com/mvrent/carrental/web/AdminBookingForm.java new file mode 100644 index 0000000..1a5eca1 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/AdminBookingForm.java @@ -0,0 +1,26 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.RentalUnit; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +/** Payload for an admin-created (counter / phone) booking. */ +public record AdminBookingForm( + Long customerId, + // walk-in customer details (used when customerId is null) + String customerName, + String customerPhone, + String customerEmail, + Long carId, + boolean withDriver, + Long driverId, + RentalUnit rentalUnit, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate pickupDate, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate returnDate, + String pickupLocation, + String dropLocation +) {} diff --git a/src/main/java/com/mvrent/carrental/web/BookingDto.java b/src/main/java/com/mvrent/carrental/web/BookingDto.java new file mode 100644 index 0000000..80e29e7 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/BookingDto.java @@ -0,0 +1,48 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Booking; + +import java.math.BigDecimal; + +/** Flat JSON view of a Booking for the AJAX Manage Bookings screen. */ +public record BookingDto( + Long id, + String customerName, + String customerEmail, + String carName, + boolean withDriver, + String driverName, + String revenueOwner, + String vendorName, + String pickupDate, + String returnDate, + BigDecimal totalAmount, + BigDecimal amountPaid, + BigDecimal balance, + String status +) { + public static BookingDto from(Booking b) { + return from(b, BigDecimal.ZERO); + } + + public static BookingDto from(Booking b, BigDecimal paid) { + BigDecimal total = b.getTotalAmount() != null ? b.getTotalAmount() : BigDecimal.ZERO; + BigDecimal amountPaid = paid != null ? paid : BigDecimal.ZERO; + return new BookingDto( + b.getId(), + b.getUser() != null ? b.getUser().getFullName() : null, + b.getUser() != null ? b.getUser().getEmail() : null, + b.getCar() != null ? b.getCar().getMake() + " " + b.getCar().getModel() : null, + b.isWithDriver(), + (b.isWithDriver() && b.getDriver() != null) ? b.getDriver().getName() : null, + b.getRevenueOwner() != null ? b.getRevenueOwner().name() : "OWNED", + b.getVendor() != null ? b.getVendor().getName() : null, + String.valueOf(b.getPickupDate()), + String.valueOf(b.getReturnDate()), + b.getTotalAmount(), + amountPaid, + total.subtract(amountPaid), + b.getStatus() != null ? b.getStatus().name() : "PENDING" + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/CarDto.java b/src/main/java/com/mvrent/carrental/web/CarDto.java new file mode 100644 index 0000000..c51ea28 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/CarDto.java @@ -0,0 +1,52 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Car; + +import java.math.BigDecimal; + +/** Flat JSON view of a Car for the AJAX Manage Cars screen. */ +public record CarDto( + Long id, + String make, + String model, + String registrationNumber, + Integer yearOfRegistration, + String colour, + String type, + int seats, + String transmission, + String fuelType, + String featureTagsCsv, + BigDecimal hourlyRate, + BigDecimal pricePerDay, + BigDecimal monthlyRate, + String ownership, + Long vendorId, + String vendorName, + BigDecimal commissionPercentOverride, + int unitsTotal, + String status, + String imageUrl, + boolean available +) { + public static CarDto from(Car c) { + return new CarDto( + c.getId(), c.getMake(), c.getModel(), c.getRegistrationNumber(), + c.getYearOfRegistration(), c.getColour(), + c.getType() != null ? c.getType().name() : null, + c.getSeats(), + c.getTransmission() != null ? c.getTransmission().name() : null, + c.getFuelType() != null ? c.getFuelType().name() : null, + String.join(", ", c.getFeatureTags()), + c.getHourlyRate(), c.getPricePerDay(), c.getMonthlyRate(), + c.getOwnership() != null ? c.getOwnership().name() : "OWNED", + c.getVendor() != null ? c.getVendor().getId() : null, + c.getVendor() != null ? c.getVendor().getName() : null, + c.getCommissionPercentOverride(), + c.getUnitsTotal(), + c.getStatus() != null ? c.getStatus().name() : "ACTIVE", + c.getImageUrl(), + c.isAvailable() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/CarForm.java b/src/main/java/com/mvrent/carrental/web/CarForm.java new file mode 100644 index 0000000..21ac584 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/CarForm.java @@ -0,0 +1,30 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.*; + +import java.math.BigDecimal; + +/** Inbound payload for creating/updating a car from the modal. */ +public record CarForm( + Long id, + String make, + String model, + String registrationNumber, + Integer yearOfRegistration, + String colour, + CarType type, + Integer seats, + Transmission transmission, + FuelType fuelType, + String featureTagsCsv, + BigDecimal hourlyRate, + BigDecimal pricePerDay, + BigDecimal monthlyRate, + CarOwnership ownership, + Long vendorId, + BigDecimal commissionPercentOverride, + Integer unitsTotal, + CarStatus status, + String imageUrl, + Boolean available +) {} diff --git a/src/main/java/com/mvrent/carrental/web/CarMediaDto.java b/src/main/java/com/mvrent/carrental/web/CarMediaDto.java new file mode 100644 index 0000000..91a9061 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/CarMediaDto.java @@ -0,0 +1,25 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Car; + +import java.util.List; + +/** Documents + photos of a car, for the AJAX media modal. */ +public record CarMediaDto(List documents, List photos) { + + public record Doc(Long id, String docType, String fileUrl, String issuedDate, String expiryDate, String state) {} + public record Photo(Long id, String url) {} + + public static CarMediaDto from(Car c) { + List docs = c.getDocuments().stream().map(d -> new Doc( + d.getId(), + d.getDocType() != null ? d.getDocType().name() : null, + d.getFileUrl(), + d.getIssuedDate() != null ? d.getIssuedDate().toString() : null, + d.getExpiryDate() != null ? d.getExpiryDate().toString() : null, + d.isExpired() ? "EXPIRED" : (d.isExpiringSoon() ? "EXPIRING" : "OK") + )).toList(); + List photos = c.getPhotos().stream().map(p -> new Photo(p.getId(), p.getUrl())).toList(); + return new CarMediaDto(docs, photos); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/CategoryDto.java b/src/main/java/com/mvrent/carrental/web/CategoryDto.java new file mode 100644 index 0000000..c1a3c37 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/CategoryDto.java @@ -0,0 +1,12 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Category; + +/** Flat JSON view of a Category for the AJAX category master screen. */ +public record CategoryDto(Long id, String type, String name, String description, boolean active) { + public static CategoryDto from(Category c) { + return new CategoryDto(c.getId(), + c.getType() != null ? c.getType().name() : null, + c.getName(), c.getDescription(), c.isActive()); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/CategoryForm.java b/src/main/java/com/mvrent/carrental/web/CategoryForm.java new file mode 100644 index 0000000..099f35e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/CategoryForm.java @@ -0,0 +1,6 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.CategoryType; + +/** Inbound payload for adding a category. */ +public record CategoryForm(CategoryType type, String name, String description) {} diff --git a/src/main/java/com/mvrent/carrental/web/ConfigForm.java b/src/main/java/com/mvrent/carrental/web/ConfigForm.java new file mode 100644 index 0000000..c8f60de --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/ConfigForm.java @@ -0,0 +1,15 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.SettlementCycle; + +import java.math.BigDecimal; + +/** Inbound payload for the System Configuration settings form. */ +public record ConfigForm( + String currencyCode, + BigDecimal defaultTaxPercent, + BigDecimal defaultCommissionPercent, + BigDecimal defaultDriverDailyCharge, + String invoicePrefix, + SettlementCycle settlementCycle +) {} diff --git a/src/main/java/com/mvrent/carrental/web/DriverDto.java b/src/main/java/com/mvrent/carrental/web/DriverDto.java new file mode 100644 index 0000000..cd9f2b8 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/DriverDto.java @@ -0,0 +1,43 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Driver; + +import java.math.BigDecimal; + +/** Flat JSON view of a Driver for the AJAX Manage Drivers screen. */ +public record DriverDto( + Long id, + String name, + String phone, + String licenceNumber, + String licenceExpiry, + boolean licenceExpired, + int yearsExperience, + Long categoryId, + String categoryName, + String employmentType, + Long vendorId, + String vendorName, + BigDecimal hourlyCharge, + BigDecimal dailyCharge, + BigDecimal monthlyCharge, + String status, + String photoUrl +) { + public static DriverDto from(Driver d) { + return new DriverDto( + d.getId(), d.getName(), d.getPhone(), d.getLicenceNumber(), + d.getLicenceExpiry() != null ? d.getLicenceExpiry().toString() : null, + d.isLicenceExpired(), + d.getYearsExperience(), + d.getCategory() != null ? d.getCategory().getId() : null, + d.getCategory() != null ? d.getCategory().getName() : null, + d.getEmploymentType() != null ? d.getEmploymentType().name() : "EMPLOYED", + d.getVendor() != null ? d.getVendor().getId() : null, + d.getVendor() != null ? d.getVendor().getName() : null, + d.getHourlyCharge(), d.getDailyCharge(), d.getMonthlyCharge(), + d.getStatus() != null ? d.getStatus().name() : "AVAILABLE", + d.getPhotoUrl() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/DriverForm.java b/src/main/java/com/mvrent/carrental/web/DriverForm.java new file mode 100644 index 0000000..3c61262 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/DriverForm.java @@ -0,0 +1,25 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.DriverEmploymentType; +import com.mvrent.carrental.model.DriverStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** Inbound payload for creating/updating a driver from the modal. */ +public record DriverForm( + Long id, + String name, + String phone, + String licenceNumber, + LocalDate licenceExpiry, + Integer yearsExperience, + Long categoryId, + DriverEmploymentType employmentType, + Long vendorId, + BigDecimal hourlyCharge, + BigDecimal dailyCharge, + BigDecimal monthlyCharge, + DriverStatus status, + String photoUrl +) {} diff --git a/src/main/java/com/mvrent/carrental/web/DriverMediaDto.java b/src/main/java/com/mvrent/carrental/web/DriverMediaDto.java new file mode 100644 index 0000000..4de819a --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/DriverMediaDto.java @@ -0,0 +1,23 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Driver; + +import java.util.List; + +/** Documents of a driver, for the AJAX documents modal. */ +public record DriverMediaDto(List documents) { + + public record Doc(Long id, String docType, String fileUrl, String issuedDate, String expiryDate, String state) {} + + public static DriverMediaDto from(Driver d) { + List docs = d.getDocuments().stream().map(x -> new Doc( + x.getId(), + x.getDocType() != null ? x.getDocType().name() : null, + x.getFileUrl(), + x.getIssuedDate() != null ? x.getIssuedDate().toString() : null, + x.getExpiryDate() != null ? x.getExpiryDate().toString() : null, + x.isExpired() ? "EXPIRED" : (x.isExpiringSoon() ? "EXPIRING" : "OK") + )).toList(); + return new DriverMediaDto(docs); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/InspectionDto.java b/src/main/java/com/mvrent/carrental/web/InspectionDto.java new file mode 100644 index 0000000..a53687e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/InspectionDto.java @@ -0,0 +1,42 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.InspectionRecord; + +import java.time.LocalDate; + +public record InspectionDto( + Long id, + Long carId, + String carName, + String regNo, + Long bookingId, + String type, + String condition, + boolean hasDamage, + LocalDate inspectionDate, + String inspector, + Integer odometer, + Integer fuelLevel, + String damageNotes, + String photoUrl +) { + public static InspectionDto from(InspectionRecord r) { + var car = r.getCar(); + return new InspectionDto( + r.getId(), + car != null ? car.getId() : null, + car != null ? car.getMake() + " " + car.getModel() : "—", + car != null ? car.getRegistrationNumber() : "—", + r.getBooking() != null ? r.getBooking().getId() : null, + r.getType() != null ? r.getType().name() : null, + r.getCondition() != null ? r.getCondition().name() : null, + r.hasDamage(), + r.getInspectionDate(), + r.getInspector(), + r.getOdometer(), + r.getFuelLevel(), + r.getDamageNotes(), + r.getPhotoUrl() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/InspectionForm.java b/src/main/java/com/mvrent/carrental/web/InspectionForm.java new file mode 100644 index 0000000..422de51 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/InspectionForm.java @@ -0,0 +1,23 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.InspectionCondition; +import com.mvrent.carrental.model.InspectionType; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; + +public record InspectionForm( + Long id, + Long carId, + Long bookingId, + InspectionType type, + InspectionCondition condition, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate inspectionDate, + String inspector, + Integer odometer, + Integer fuelLevel, + String damageNotes, + String photoUrl +) {} diff --git a/src/main/java/com/mvrent/carrental/web/MaintenanceDto.java b/src/main/java/com/mvrent/carrental/web/MaintenanceDto.java new file mode 100644 index 0000000..34a2a70 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/MaintenanceDto.java @@ -0,0 +1,45 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.MaintenanceRecord; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record MaintenanceDto( + Long id, + Long carId, + String carName, + String regNo, + String type, + String status, + boolean open, + LocalDate serviceDate, + Integer odometer, + BigDecimal cost, + String garage, + String notes, + LocalDate nextServiceDate, + Integer nextServiceOdometer, + boolean dueSoon +) { + public static MaintenanceDto from(MaintenanceRecord m) { + var car = m.getCar(); + return new MaintenanceDto( + m.getId(), + car != null ? car.getId() : null, + car != null ? car.getMake() + " " + car.getModel() : "—", + car != null ? car.getRegistrationNumber() : "—", + m.getType() != null ? m.getType().name() : null, + m.getStatus() != null ? m.getStatus().name() : null, + m.isOpen(), + m.getServiceDate(), + m.getOdometer(), + m.getCost(), + m.getGarage(), + m.getNotes(), + m.getNextServiceDate(), + m.getNextServiceOdometer(), + m.isDueSoon() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/MaintenanceForm.java b/src/main/java/com/mvrent/carrental/web/MaintenanceForm.java new file mode 100644 index 0000000..ebb48d9 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/MaintenanceForm.java @@ -0,0 +1,25 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.MaintenanceStatus; +import com.mvrent.carrental.model.MaintenanceType; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record MaintenanceForm( + Long id, + Long carId, + MaintenanceType type, + MaintenanceStatus status, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate serviceDate, + Integer odometer, + BigDecimal cost, + String garage, + String notes, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate nextServiceDate, + Integer nextServiceOdometer +) {} diff --git a/src/main/java/com/mvrent/carrental/web/OrgForm.java b/src/main/java/com/mvrent/carrental/web/OrgForm.java new file mode 100644 index 0000000..fc58fe2 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/OrgForm.java @@ -0,0 +1,13 @@ +package com.mvrent.carrental.web; + +/** Inbound payload for the Organization settings form. */ +public record OrgForm( + String name, + String legalName, + String logoUrl, + String address, + String gstNumber, + String contactEmail, + String phone, + String currencyCode +) {} diff --git a/src/main/java/com/mvrent/carrental/web/PaymentDto.java b/src/main/java/com/mvrent/carrental/web/PaymentDto.java new file mode 100644 index 0000000..9048f47 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/PaymentDto.java @@ -0,0 +1,30 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Payment; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record PaymentDto( + Long id, + Long bookingId, + BigDecimal amount, + String method, + String reference, + LocalDate paidAt, + String notes, + String recordedBy +) { + public static PaymentDto from(Payment p) { + return new PaymentDto( + p.getId(), + p.getBooking() != null ? p.getBooking().getId() : null, + p.getAmount(), + p.getMethod() != null ? p.getMethod().name() : null, + p.getReference(), + p.getPaidAt(), + p.getNotes(), + p.getRecordedBy() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/PaymentForm.java b/src/main/java/com/mvrent/carrental/web/PaymentForm.java new file mode 100644 index 0000000..58ff9b3 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/PaymentForm.java @@ -0,0 +1,17 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.PaymentMethod; +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.format.annotation.DateTimeFormat; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record PaymentForm( + BigDecimal amount, + PaymentMethod method, + String reference, + @JsonFormat(pattern = "yyyy-MM-dd") + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate paidAt, + String notes +) {} diff --git a/src/main/java/com/mvrent/carrental/web/ReviewDto.java b/src/main/java/com/mvrent/carrental/web/ReviewDto.java new file mode 100644 index 0000000..64450cc --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/ReviewDto.java @@ -0,0 +1,33 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Review; + +import java.time.LocalDateTime; + +public record ReviewDto( + Long id, + Long carId, + String carName, + String customerName, + Long bookingId, + int rating, + String comment, + boolean approved, + LocalDateTime createdAt +) { + public static ReviewDto from(Review r) { + var car = r.getCar(); + var u = r.getUser(); + return new ReviewDto( + r.getId(), + car != null ? car.getId() : null, + car != null ? car.getMake() + " " + car.getModel() : "—", + u != null ? (u.getFullName() != null ? u.getFullName() : u.getUsername()) : "—", + r.getBooking() != null ? r.getBooking().getId() : null, + r.getRating(), + r.getComment(), + r.isApproved(), + r.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/StaffDto.java b/src/main/java/com/mvrent/carrental/web/StaffDto.java new file mode 100644 index 0000000..c676c1e --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/StaffDto.java @@ -0,0 +1,30 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Permission; +import com.mvrent.carrental.model.User; + +import java.util.List; + +public record StaffDto( + Long id, + String username, + String fullName, + String email, + String phone, + String role, + boolean enabled, + List permissions +) { + public static StaffDto from(User u) { + return new StaffDto( + u.getId(), + u.getUsername(), + u.getFullName(), + u.getEmail(), + u.getPhone(), + u.getRole() != null ? u.getRole().name() : null, + u.isEnabled(), + u.getPermissions() != null ? u.getPermissions().stream().map(Permission::name).sorted().toList() : List.of() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/StaffForm.java b/src/main/java/com/mvrent/carrental/web/StaffForm.java new file mode 100644 index 0000000..1817e93 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/StaffForm.java @@ -0,0 +1,19 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Permission; +import com.mvrent.carrental.model.Role; + +import java.util.Set; + +/** Create/edit payload for an admin or staff user. */ +public record StaffForm( + Long id, + String username, + String fullName, + String email, + String phone, + Role role, + String password, + Boolean enabled, + Set permissions +) {} diff --git a/src/main/java/com/mvrent/carrental/web/TelematicsPing.java b/src/main/java/com/mvrent/carrental/web/TelematicsPing.java new file mode 100644 index 0000000..fe02ffc --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/TelematicsPing.java @@ -0,0 +1,12 @@ +package com.mvrent.carrental.web; + +/** Payload a tracking device (or simulator) posts to the ingest endpoint. */ +public record TelematicsPing( + Long carId, + Double lat, + Double lng, + Double speed, + Double heading, + Integer odometer, + Boolean ignition +) {} diff --git a/src/main/java/com/mvrent/carrental/web/VehicleLocationDto.java b/src/main/java/com/mvrent/carrental/web/VehicleLocationDto.java new file mode 100644 index 0000000..cc6c766 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/VehicleLocationDto.java @@ -0,0 +1,38 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.VehicleLocation; + +import java.time.LocalDateTime; + +public record VehicleLocationDto( + Long carId, + String carName, + String regNo, + String status, + double latitude, + double longitude, + Double speedKph, + Double heading, + Integer odometer, + Boolean ignitionOn, + boolean live, + LocalDateTime updatedAt +) { + public static VehicleLocationDto from(VehicleLocation v) { + var car = v.getCar(); + return new VehicleLocationDto( + car != null ? car.getId() : null, + car != null ? car.getMake() + " " + car.getModel() : "—", + car != null ? car.getRegistrationNumber() : "—", + car != null && car.getStatus() != null ? car.getStatus().name() : null, + v.getLatitude(), + v.getLongitude(), + v.getSpeedKph(), + v.getHeading(), + v.getOdometer(), + v.getIgnitionOn(), + v.isLive(), + v.getUpdatedAt() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/VendorDto.java b/src/main/java/com/mvrent/carrental/web/VendorDto.java new file mode 100644 index 0000000..619784d --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/VendorDto.java @@ -0,0 +1,27 @@ +package com.mvrent.carrental.web; + +import com.mvrent.carrental.model.Vendor; + +import java.math.BigDecimal; + +/** Flat JSON view of a Vendor for the AJAX Manage Vendors screen. */ +public record VendorDto( + Long id, + String name, + String contactPerson, + String phone, + String email, + String address, + String gstNumber, + String settlementDetails, + BigDecimal commissionPercent, + boolean active +) { + public static VendorDto from(Vendor v) { + return new VendorDto( + v.getId(), v.getName(), v.getContactPerson(), v.getPhone(), v.getEmail(), + v.getAddress(), v.getGstNumber(), v.getSettlementDetails(), + v.getCommissionPercent(), v.isActive() + ); + } +} diff --git a/src/main/java/com/mvrent/carrental/web/VendorForm.java b/src/main/java/com/mvrent/carrental/web/VendorForm.java new file mode 100644 index 0000000..92553e9 --- /dev/null +++ b/src/main/java/com/mvrent/carrental/web/VendorForm.java @@ -0,0 +1,17 @@ +package com.mvrent.carrental.web; + +import java.math.BigDecimal; + +/** Inbound payload for creating/updating a vendor from the modal. */ +public record VendorForm( + Long id, + String name, + String contactPerson, + String phone, + String email, + String address, + String gstNumber, + String settlementDetails, + BigDecimal commissionPercent, + Boolean active +) {} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..6650421 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,59 @@ +spring.application.name=car-rental + +# PostgreSQL — override via env vars or edit here +spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/mv_rent} +spring.datasource.username=${DB_USER:postgres} +spring.datasource.password=${DB_PASSWORD:postgres} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.defer-datasource-initialization=true + +# Run data.sql after Hibernate creates schema +spring.sql.init.mode=always +spring.sql.init.continue-on-error=true + +# Google OAuth2 login — set real values via env vars to activate. +# Placeholders keep the app booting; Google will reject 'not-configured' until set. +spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID:not-configured} +spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:not-configured} +spring.security.oauth2.client.registration.google.scope=openid,profile,email + +# Thymeleaf +spring.thymeleaf.cache=false + +# File uploads (car/driver documents & photos) — configurable storage dir + limits +app.storage.location=${STORAGE_DIR:./uploads} +app.storage.url-prefix=/uploads +spring.servlet.multipart.max-file-size=${MAX_UPLOAD_SIZE:10MB} +spring.servlet.multipart.max-request-size=${MAX_UPLOAD_SIZE:10MB} + +# Currency shown across the UI and PDFs. The default symbol uses a \uXXXX escape +# so the file stays ASCII-safe (₹ = the rupee sign). Override via env var. +app.currency.symbol=${CURRENCY_SYMBOL:\u20B9} +app.currency.code=${CURRENCY_CODE:INR} + +# PDF currency font — a TTF that contains the rupee glyph (U+20B9). Blank disables embedding. +app.pdf.font=${PDF_FONT:classpath:fonts/DejaVuSans.ttf} + +# Telematics ingest API key (GPS devices send it as the X-API-KEY header). Blank = accept all (dev only). +app.telematics.api-key=${TELEMATICS_API_KEY:} + +# Email notifications — set spring.mail.host + credentials to activate. Blank host = notifications log only. +spring.mail.host=${MAIL_HOST:} +spring.mail.port=${MAIL_PORT:587} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +app.mail.enabled=${MAIL_ENABLED:false} +app.mail.from=${MAIL_FROM:no-reply@mvrent.local} +app.mail.admin=${MAIL_ADMIN:admin@mvrent.local} +# SMS notifications — pluggable; logs only until a provider is wired in. +app.sms.enabled=${SMS_ENABLED:false} + +server.port=8080 + +logging.level.org.springframework.security=INFO diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..4f4b76d --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,24 @@ +-- Seed cars (users are seeded from DataInitializer.java using the real password encoder) +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'Volkswagen', 'Virtus', 'KL07AA1001', 'SEDAN', 5, 'MANUAL', 'PETROL', 2200.00, '/images/virtus.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1001'); + +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'Mahindra', 'Thar', 'KL07AA1002', 'SUV', 4, 'MANUAL', 'DIESEL', 3500.00, '/images/thar.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1002'); + +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'MG', 'Hector', 'KL07AA1003', 'SUV', 7, 'AUTOMATIC', 'PETROL', 3200.00, '/images/hector.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1003'); + +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'BMW', 'X3', 'KL07AA1004', 'LUXURY', 5, 'AUTOMATIC', 'PETROL', 8500.00, '/images/bmwx3.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1004'); + +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'Royal Enfield', 'Classic 350', 'KL07AA1005', 'BIKE', 2, 'MANUAL', 'PETROL', 800.00, '/images/bike.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1005'); + +INSERT INTO cars (make, model, registration_number, type, seats, transmission, fuel_type, price_per_day, image_url, available) +SELECT 'Maruti', 'Swift', 'KL07AA1006', 'HATCHBACK', 5, 'MANUAL', 'PETROL', 1500.00, '/images/car.png', true +WHERE NOT EXISTS (SELECT 1 FROM cars WHERE registration_number = 'KL07AA1006'); diff --git a/src/main/resources/fonts/DejaVuSans.ttf b/src/main/resources/fonts/DejaVuSans.ttf new file mode 100644 index 0000000..9d40c32 Binary files /dev/null and b/src/main/resources/fonts/DejaVuSans.ttf differ diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css new file mode 100644 index 0000000..f10aa94 --- /dev/null +++ b/src/main/resources/static/css/app.css @@ -0,0 +1,1105 @@ +/* === Base === */ +*{margin:0;padding:0;box-sizing:border-box;font-family:'Poppins',sans-serif} +body{background:#fff;color:#222;line-height:1.5} +a{text-decoration:none;color:inherit} +img{max-width:100%;display:block} +.container{max-width:1280px;margin:0 auto;padding:0 20px} + +/* === Top bar === */ +.topbar{background:#fff;border-bottom:1px solid #eee;padding:14px 0} +.topbar-inner{display:flex;align-items:center;justify-content:space-between;gap:20px} +.logo img{height:46px} +.logo-text{font-size:24px;font-weight:800;color:#f7941d;letter-spacing:.5px} +.logo-text span{color:#222} +.nav-top{display:flex;gap:28px;align-items:center} +.nav-top a{font-size:14px;font-weight:500;color:#333} +.nav-top a:hover{color:#f7941d} +.auth-buttons{display:flex;gap:10px;align-items:center} +.user-chip{font-size:13px;color:#555;padding:6px 12px;background:#fff5e8;border-radius:20px} +.btn{display:inline-block;padding:9px 20px;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer;border:none;transition:.2s;text-align:center} +.btn-outline{background:#fff;border:1.5px solid #f7941d;color:#f7941d} +.btn-outline:hover{background:#f7941d;color:#fff} +.btn-primary{background:#f7941d;color:#fff} +.btn-primary:hover{background:#e07d00} +.btn-ghost{background:transparent;color:#555} +.btn-ghost:hover{color:#f7941d} +.btn-danger{background:#e23b3b;color:#fff} +.btn-danger:hover{background:#b82d2d} +.btn-sm{padding:6px 14px;font-size:13px} +.menu-toggle{display:none;cursor:pointer;background:none;border:none} +.menu-toggle img{height:28px} + +/* === Mega menu strip === */ +.mega-strip{background:#1a1a1a;color:#fff} +.mega-strip ul{display:flex;justify-content:center;gap:32px;list-style:none;padding:12px 0;flex-wrap:wrap} +.mega-strip a{font-size:13px;font-weight:500;color:#ddd} +.mega-strip a:hover{color:#f7941d} + +/* === Hero carousel === */ +.hero{position:relative;overflow:hidden;background:#000} +.slides{position:relative;width:100%;height:480px} +.slide{position:absolute;inset:0;opacity:0;transition:opacity .8s ease;background-size:cover;background-position:center} +.slide.active{opacity:1} +.hero-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;color:#fff;background:linear-gradient(to bottom,rgba(0,0,0,.15),rgba(0,0,0,.4))} +.hero-overlay h1{font-size:48px;font-weight:800;text-shadow:0 2px 10px rgba(0,0,0,.5);margin-bottom:16px} +.hero-overlay p{font-size:18px;text-shadow:0 1px 6px rgba(0,0,0,.6);margin-bottom:24px} +.dots{position:absolute;bottom:16px;left:0;right:0;display:flex;justify-content:center;gap:8px;z-index:3} +.dot{width:10px;height:10px;border-radius:50%;background:rgba(255,255,255,.5);cursor:pointer;border:none} +.dot.active{background:#f7941d} + +/* === Booking form === */ +.booking-wrap{background:#f5f7fa;padding:36px 0} +.booking-card{background:#fff;border-radius:14px;box-shadow:0 8px 30px rgba(0,0,0,.06);padding:28px;margin-top:-80px;position:relative;z-index:2} +.tabs{display:flex;gap:8px;margin-bottom:22px;flex-wrap:wrap;align-items:center} +.tab{display:flex;align-items:center;gap:8px;padding:10px 22px;border:1px solid #e5e7eb;border-radius:30px;background:#fff;cursor:pointer;font-weight:500;color:#555;transition:.18s ease;font-size:14px} +.tab:hover{border-color:#f7941d;color:#f7941d} +.tab.active{border-color:#f7941d;color:#f7941d;background:#fff7ee;font-weight:600} +.tab img{height:22px} +.spot-cta{margin-left:auto;display:flex;align-items:center;gap:8px;padding:10px 18px;background:#fff5e8;color:#f7941d;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;transition:.18s ease} +.spot-cta:hover{background:#fff0d6} +.spot-cta img{height:20px} + +.booking-grid{display:grid;grid-template-columns:1fr 1fr 1fr 1fr auto;gap:14px;align-items:end} + +/* Standard, clean form-field styling */ +.field{display:flex;flex-direction:column} +.field label{display:block;font-size:12px;color:#555;margin-bottom:8px;font-weight:600;letter-spacing:.2px} +.field input, +.field select, +.field textarea, +.promo-row input{ + width:100%; + height:46px; + padding:0 14px; + border:1px solid #d4d4d8; + border-radius:8px; + font-size:14px; + background:#fff; + color:#222; + outline:none; + font-family:inherit; + transition:border-color .15s ease, box-shadow .15s ease; + box-shadow:0 1px 2px rgba(0,0,0,.02); +} +.field textarea{height:auto;padding:12px 14px;min-height:96px;resize:vertical} +.field input:hover,.field select:hover,.field textarea:hover, +.promo-row input:hover{border-color:#a1a1aa} +.field input:focus, +.field select:focus, +.field textarea:focus, +.promo-row input:focus{ + border-color:#f7941d; + box-shadow:0 0 0 3px rgba(247,148,29,.15); +} +.field input:disabled,.field select:disabled{ + background:#f5f5f5;color:#999;cursor:not-allowed; +} + +/* Custom dropdown chevron on + + + + + + +
+ + + + + + + + + + +
WhenUserRoleActionEntityIDDetailIP
Loading…
+
+
+ + + + + + +
+ + diff --git a/src/main/resources/templates/admin/bookings.html b/src/main/resources/templates/admin/bookings.html new file mode 100644 index 0000000..895a4e6 --- /dev/null +++ b/src/main/resources/templates/admin/bookings.html @@ -0,0 +1,403 @@ + + + + + + +
+
+ + +
+ + + + + +
+ +
+ + + + + + + + + + + + + +
IDCustomerCarDriverDatesOwnerTotalPaidStatus
Loading bookings…
+
+
+
+
+ + + + + + + +
+ + + + + + +
+ + diff --git a/src/main/resources/templates/admin/car-form.html b/src/main/resources/templates/admin/car-form.html new file mode 100644 index 0000000..0a72977 --- /dev/null +++ b/src/main/resources/templates/admin/car-form.html @@ -0,0 +1,220 @@ + + + + + + +
+
+ +
+ +
+

Add Car

+

Vehicle details, rates, ownership & status

+ +
+ + +

Basic details

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +
+
+ + +
+
+ +

Classification

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Rates ([[${currencySymbol}]])

+
+
+ + +
+
+ + + +
+
+ + +
+
+ +

Ownership & availability

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + Cancel +
+
+ + +
+

Documents

+

RC, insurance, permit & more — with expiry tracking

+ +
+ + + + + + + + + + + + +
TypeFileIssuedExpiryState
RCview + EXPIRED + EXPIRING + OK + +
+ +
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+

Photos

+

Gallery shown on the customer page

+ +
+
+ car photo +
+ +
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/cars.html b/src/main/resources/templates/admin/cars.html new file mode 100644 index 0000000..24503fc --- /dev/null +++ b/src/main/resources/templates/admin/cars.html @@ -0,0 +1,507 @@ + + + + + + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + + +
IDReg NoMake / ModelYearTypeOwnerPrice/dayStatusListed
Loading cars…
+
+
+
+
+ + + + + +
+
+
+

Documents & Photos

+ +
+
+ + +
+
+ +
+
Loading…
+
+
Add document
+
+
+ + +
+
+ +
+ +
Choose file
+
+
+
+
+ +
+
+
+ + + +
+
+ +
+
+
+ +
+ + + + + + +
+ + diff --git a/src/main/resources/templates/admin/categories.html b/src/main/resources/templates/admin/categories.html new file mode 100644 index 0000000..afeedb1 --- /dev/null +++ b/src/main/resources/templates/admin/categories.html @@ -0,0 +1,130 @@ + + + + + + +
+
+
+ +
+
+

Category Masters

+

Vehicle, driver & customer categories used across the system.

+
+
+ + ← Settings +
+
+ +
+ +
+
+ 🚗 +

Vehicle 0

Hatchback, Sedan, SUV…

+
+
+
+ + +
+
+ + +
+
+ 👤 +

Driver 0

Standard, Premium, Chauffeur…

+
+
+
+ + +
+
+ + +
+
+ 🧑 +

Customer 0

Individual, Company, Corporate…

+
+
+
+ + +
+
+
+ +
+
+
+ +
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..0fedaa0 --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,149 @@ + + + + + + +
+
+ + +
+
+

Command Center

+

Real-time overview of fleet, drivers, vendors & revenue

+
+
+
Total Revenue
+
[[${currencySymbol}]]0
+
+
+ + +
+
🚗
0
Cars
+
👤
0
Drivers
+
🏢
0
Vendors
+
🧑
0
Customers
+
📅
0
Bookings
+
0
Confirmed
+
+ + +
+
+

Revenue Trend

+ +
+
+

Bookings by Status

+ +
+
+

Owned vs Vendor

+ +
+
+ + +
+
+

⚠ Car documents expiring (30 days)

+

✓ Car documents

+
    +
  • + car · + RC — + date + (EXPIRED) +
  • +
+
All car documents are valid.
+
+ +
+

⚠ Driver documents expiring (30 days)

+

✓ Driver documents

+
    +
  • + driver · + LICENCE — + date + (EXPIRED) +
  • +
+
All driver documents are valid.
+
+
+ + +
+

Recent Bookings

+ + + + + + + + + + + + + +
IDCustomerCarDatesTotalStatus
#1namecardates[[${currencySymbol}]]0PENDING
No bookings yet.
+
+
+
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/driver-form.html b/src/main/resources/templates/admin/driver-form.html new file mode 100644 index 0000000..5b833ca --- /dev/null +++ b/src/main/resources/templates/admin/driver-form.html @@ -0,0 +1,155 @@ + + + + + + +
+
+ +
+ +
+

Add Driver

+

Personal details, category, charges & availability

+ +
+ + +

Personal details

+
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Category & employment

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Charges ([[${currencySymbol}]])

+
+
+ + +
+
+ + +
+
+ + +
+
+ + + Cancel +
+
+ + +
+

Documents

+

Licence, ID proof, police verification — with expiry tracking

+ +
+ + + + + + + + + + + + +
TypeFileIssuedExpiryState
LICENCEview + EXPIRED + EXPIRING + OK + +
+ +
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/driver-profile.html b/src/main/resources/templates/admin/driver-profile.html new file mode 100644 index 0000000..79b54a7 --- /dev/null +++ b/src/main/resources/templates/admin/driver-profile.html @@ -0,0 +1,119 @@ + + + + + + +
+
+ + + +
+
+ + +
+
+
+ photo + RK +
+
+

Driver

+
+ Standard + EMPLOYED + AVAILABLE + ⚠ LICENCE EXPIRED +
+
+
+ Edit +
+
+
+ + +
+
🎖
0 yrs
Experience
+
📍
0
Total Trips
+
AVAILABLE
Status
+
💰
[[${currencySymbol}]]
Daily Charge
+
+ + +
+
+

Details

+
Phone
+
Licence No.DL
+
Licence Expiry
+
CategoryStandard
+
EmploymentEMPLOYED
+
Vendorvendor
+
Hourly Charge[[${currencySymbol}]]
+
Daily Charge[[${currencySymbol}]]
+
Monthly Charge[[${currencySymbol}]]
+
+ +
+

Documents

+
No documents uploaded.
+
+
+
+ LICENCE + EXPIRED + EXPIRING + OK +
+
Issued · Expires
+ View file ↗ +
+
+
+
+ + +
+

Driver Portal Login

+

Create credentials so this driver can sign in at /driver/login to see their trips.

+
+
+
+
+ +
+
+ + +
+

Trip History

+ + + + + + + + + + + + + +
IDCustomerCarPickupReturnTotalStatus
#1namecardatedate[[${currencySymbol}]]0PENDING
+
No trips recorded yet.
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/drivers.html b/src/main/resources/templates/admin/drivers.html new file mode 100644 index 0000000..7b2ca3f --- /dev/null +++ b/src/main/resources/templates/admin/drivers.html @@ -0,0 +1,389 @@ + + + + + + +
+
+ + +
+ + +
+ +
+ + + + + + + + + + + + +
IDNamePhoneLicenceCategoryEmploymentDay ChargeExpStatus
Loading drivers…
+
+
+
+
+ + + + + +
+
+
+

Documents

+ +
+
+
Loading…
+
+
Add document
+
+
+ + +
+
+ +
+ +
Choose file
+
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+ + + + + + +
+ + diff --git a/src/main/resources/templates/admin/inspections.html b/src/main/resources/templates/admin/inspections.html new file mode 100644 index 0000000..e399b62 --- /dev/null +++ b/src/main/resources/templates/admin/inspections.html @@ -0,0 +1,227 @@ + + + + + + +
+
+ + +
+ + + + + +
+ +
+ + + + + + + + + + +
CarTypeConditionDateInspectorOdoFuelPhoto
Loading…
+
+
+
+
+ + + + +
+ + + + + +
+ + diff --git a/src/main/resources/templates/admin/login.html b/src/main/resources/templates/admin/login.html new file mode 100644 index 0000000..cdde636 --- /dev/null +++ b/src/main/resources/templates/admin/login.html @@ -0,0 +1,50 @@ + + + + + +
+ + + + + +
+

Sign in to Admin

+

Use your staff credentials to continue

+ +
Invalid admin credentials or not authorised
+
You have been signed out
+ +
+
+ + +
+
+ + +
+ +
+ +

+ ← Back to main site +

+
+ +
+ + + diff --git a/src/main/resources/templates/admin/maintenance.html b/src/main/resources/templates/admin/maintenance.html new file mode 100644 index 0000000..718a9a9 --- /dev/null +++ b/src/main/resources/templates/admin/maintenance.html @@ -0,0 +1,204 @@ + + + + + + +
+
+ + +
+ + + + + +
+ +
+ + + + + + + + + + +
CarTypeStatusService dateOdometerCostGarageNext due
Loading…
+
+
+
+
+ + + + +
+ + + + + +
+ + diff --git a/src/main/resources/templates/admin/profile.html b/src/main/resources/templates/admin/profile.html new file mode 100644 index 0000000..1ae14d0 --- /dev/null +++ b/src/main/resources/templates/admin/profile.html @@ -0,0 +1,82 @@ + + + + + + +
+
+ + +
+ +
+
+ AD +
+

Name

+ ADMIN +
+
+ + + + + + + + +
Usernameadmin
Emailemail
Phone
RoleADMIN
Permissions
+
+ + +
+

Change Password

+

Use a strong password of at least 6 characters

+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/report-drivers.html b/src/main/resources/templates/admin/report-drivers.html new file mode 100644 index 0000000..0a4a91f --- /dev/null +++ b/src/main/resources/templates/admin/report-drivers.html @@ -0,0 +1,36 @@ + + + + + + +
+
+ + +
+ + + + + + + + + + + +
DriverCategoryTripsDriver Charges Earned
drivercat0[[${currencySymbol}]]0
No drivers.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/report-vendors.html b/src/main/resources/templates/admin/report-vendors.html new file mode 100644 index 0000000..d213ad0 --- /dev/null +++ b/src/main/resources/templates/admin/report-vendors.html @@ -0,0 +1,37 @@ + + + + + + +
+
+ + +
+ + + + + + + + + + + + +
VendorTripsGrossCommissionNet Payable
vendor0[[${currencySymbol}]]0[[${currencySymbol}]]0[[${currencySymbol}]]0
No vendors.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/reports.html b/src/main/resources/templates/admin/reports.html new file mode 100644 index 0000000..c698ce1 --- /dev/null +++ b/src/main/resources/templates/admin/reports.html @@ -0,0 +1,112 @@ + + + + + + +
+
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+ +
+

Bookings

0
+

Gross

[[${currencySymbol}]]0
+

Tax

[[${currencySymbol}]]0
+

Total

[[${currencySymbol}]]0
+
+ +
+ + + + + + + + + + + + + + + + + +
IDCustomerCarDriverOwnerPickupReturnVehicleTotalStatus
1namecarSelfOWNEDdatedate[[${currencySymbol}]]0[[${currencySymbol}]]0PENDING
No bookings match the filters.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/reviews.html b/src/main/resources/templates/admin/reviews.html new file mode 100644 index 0000000..fcb495d --- /dev/null +++ b/src/main/resources/templates/admin/reviews.html @@ -0,0 +1,112 @@ + + + + + + +
+
+ + +
+ + + + + +
+ +
+ + + + + + + + + +
WhenCarCustomerRatingCommentStatus
Loading…
+
+
+
+
+ +
+ + + + + +
+ + diff --git a/src/main/resources/templates/admin/settings.html b/src/main/resources/templates/admin/settings.html new file mode 100644 index 0000000..dccbc79 --- /dev/null +++ b/src/main/resources/templates/admin/settings.html @@ -0,0 +1,223 @@ + + + + + + +
+
+
+ +
+

Settings

+

Manage your organization profile, billing defaults and master data.

+
+ +
+ + + + +
+ + +
+
+
+ 🏢 +
+

Organization profile

+

Company identity & branding shown across the app and on documents.

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shown on invoices, settlement statements & the public site. + +
+
+
+
+ + + + + + + +
+
+ +
+
+
+ +
+ + + + +
+ + diff --git a/src/main/resources/templates/admin/settlement-detail.html b/src/main/resources/templates/admin/settlement-detail.html new file mode 100644 index 0000000..50de51a --- /dev/null +++ b/src/main/resources/templates/admin/settlement-detail.html @@ -0,0 +1,79 @@ + + + + + + +
+
+ + +
+ +
+
+

Vendor

+

period

+ + + + + + + + +
Trips settled0
Gross rental revenue[[${currencySymbol}]]0
Less: commission- [[${currencySymbol}]]0
Add: vendor driver charges+ [[${currencySymbol}]]0
Net payable[[${currencySymbol}]]0
+
+ +
+

Status

+

Current: + DRAFT +

+
+
+ + +
+ +
+
+
+ +

Trips in this period

+
+ + + + + + + + + + + + + + +
BookingCarPickupReturnVehicleCommissionDriver
#1cardatedate[[${currencySymbol}]]0[[${currencySymbol}]]0[[${currencySymbol}]]0
No trips in this period.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/settlements.html b/src/main/resources/templates/admin/settlements.html new file mode 100644 index 0000000..fcf2214 --- /dev/null +++ b/src/main/resources/templates/admin/settlements.html @@ -0,0 +1,46 @@ + + + + + + +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + +
IDVendorPeriodTripsGrossCommissionNet PayableStatus
1vendorperiod0[[${currencySymbol}]]0[[${currencySymbol}]]0[[${currencySymbol}]]0 + DRAFT + + Open +
No settlements generated yet.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/tracking.html b/src/main/resources/templates/admin/tracking.html new file mode 100644 index 0000000..dec44bd --- /dev/null +++ b/src/main/resources/templates/admin/tracking.html @@ -0,0 +1,81 @@ + + + + + + + + + + +
+
+ + +
+
+ +
+

+ Devices post positions to POST /api/telematics/ingest (header X-API-KEY). Map auto-refreshes every 15s. +

+
+
+ + + +
+ + diff --git a/src/main/resources/templates/admin/users.html b/src/main/resources/templates/admin/users.html new file mode 100644 index 0000000..ab38ebe --- /dev/null +++ b/src/main/resources/templates/admin/users.html @@ -0,0 +1,227 @@ + + + + + + +
+
+ + +
+ + + + + +
+ +
+ + + + + + + + + +
NameUsernameEmailRolePermissionsStatus
Loading…
+
+
+
+
+ + + + +
+ + + + + +
+ + diff --git a/src/main/resources/templates/admin/vendor-detail.html b/src/main/resources/templates/admin/vendor-detail.html new file mode 100644 index 0000000..35ba648 --- /dev/null +++ b/src/main/resources/templates/admin/vendor-detail.html @@ -0,0 +1,141 @@ + + + + + + +
+
+ + + +
+
+ + +
+
+
KC
+
+

Vendor

+
+ ACTIVE + 15% commission + contact +
+
+
+ Edit +
+
+
+ + +
+
🚗
0
Cars
+
👤
0
Drivers
+
📍
0
Trips
+
💰
[[${currencySymbol}]]0
Net Payable
+
+ + +
+
+

Details

+
Contact Person
+
Phone
+
Email
+
GST Number
+
Commission15%
+
Settlement
+
Gross Revenue[[${currencySymbol}]]0
+
Commission Earned[[${currencySymbol}]]0
+
+ +
+

Portal Login

+

Create credentials for the /vendor portal

+
+
+
+
+ +
+
+
+ + +
+

Settlements

+
+
Generate settlement
+
+
+
+ +
+
+ + + + + + + + + + + + + + +
IDPeriodTripsGrossNet PayableStatus
#1period0[[${currencySymbol}]]0[[${currencySymbol}]]0DRAFTOpen
No settlements yet.
+
+ + +
+
+

Attached Cars

+ + + + + + + + + + +
Reg NoMake / ModelPrice/day
REGname[[${currencySymbol}]]2000
No cars attached.
+
+ +
+

Attached Drivers

+ + + + + + + + + + +
NameDay ChargeStatus
name[[${currencySymbol}]]800AVAILABLE
No drivers attached.
+
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/admin/vendor-form.html b/src/main/resources/templates/admin/vendor-form.html new file mode 100644 index 0000000..7c56564 --- /dev/null +++ b/src/main/resources/templates/admin/vendor-form.html @@ -0,0 +1,67 @@ + + + + + + +
+
+
+

Add Vendor

+

Profile, settlement details & commission

+ +
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + Cancel +
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/admin/vendors.html b/src/main/resources/templates/admin/vendors.html new file mode 100644 index 0000000..e51f219 --- /dev/null +++ b/src/main/resources/templates/admin/vendors.html @@ -0,0 +1,194 @@ + + + + + + +
+
+ + +
+ + +
+ +
+ + + + + + + + + +
IDNameContactPhoneCommission %Active
Loading vendors…
+
+
+
+
+ + + + +
+ + + + + + +
+ + diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html new file mode 100644 index 0000000..fc27e24 --- /dev/null +++ b/src/main/resources/templates/auth/login.html @@ -0,0 +1,64 @@ + + + + + +
+ + + + + +
+

Welcome back

+

Login to continue with your booking

+ +
Invalid username or password
+
You have been logged out
+
+ + + +
or sign in with email
+ +
+
+ + +
+
+ + +
+ +
+ +

New here? Create an account

+
+ +
+ + + diff --git a/src/main/resources/templates/auth/register.html b/src/main/resources/templates/auth/register.html new file mode 100644 index 0000000..da82d9b --- /dev/null +++ b/src/main/resources/templates/auth/register.html @@ -0,0 +1,87 @@ + + + + + +
+ + + + + +
+

Create your account

+

Sign up to book cars in seconds

+ +
+ + + +
or register with email
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +

Already have an account? Login

+
+ +
+ + + diff --git a/src/main/resources/templates/bookings/form.html b/src/main/resources/templates/bookings/form.html new file mode 100644 index 0000000..7703359 --- /dev/null +++ b/src/main/resources/templates/bookings/form.html @@ -0,0 +1,75 @@ + + + + + + +
+
+
+

Book the car

+

+ [[${currencySymbol}]]2000 / day + · [[${currencySymbol}]]0 / month + · Manual + · Petrol +

+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

Driver

+
+ +
+
+ + + Driver charges are added on top of the vehicle rate. Tax applies to the total. +
+ + +
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/bookings/invoice.html b/src/main/resources/templates/bookings/invoice.html new file mode 100644 index 0000000..08512e1 --- /dev/null +++ b/src/main/resources/templates/bookings/invoice.html @@ -0,0 +1,81 @@ + + + + + + +
+
+
+
+
+
MVRent
+

Self drive car rentals

+
+
+

INVOICE

+

INV-00001

+

date

+
+
+ +
+
+

Billed To

+

name

+

email

+
+
+

Booking

+

car

+

dates

+

loc

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DescriptionAmount
Vehicle[[${currencySymbol}]]0
Driver[[${currencySymbol}]]0
Tax[[${currencySymbol}]]0
Total[[${currencySymbol}]]0
Amount paid[[${currencySymbol}]]0
Balance due[[${currencySymbol}]]0
+ +
+ Status: + PENDING + + + Download PDF + + +
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/bookings/my.html b/src/main/resources/templates/bookings/my.html new file mode 100644 index 0000000..52adfed --- /dev/null +++ b/src/main/resources/templates/bookings/my.html @@ -0,0 +1,48 @@ + + + + + + +
+
+ + +
+ +
+ You haven't made any bookings yet. Browse cars → +
+ +
+ + + + + + + + + + + + + + + + + + + +
IDCarDriverPickupReturnTotalStatus
1carSelf-drivedatedate[[${currencySymbol}]]0 + PENDING + Invoice
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/cars/detail.html b/src/main/resources/templates/cars/detail.html new file mode 100644 index 0000000..362a17d --- /dev/null +++ b/src/main/resources/templates/cars/detail.html @@ -0,0 +1,153 @@ + + + + + + +
+
+
+
+
+ +
C
+
+
+ photo +
+
+ +
+ SUV +

Car name

+

+ Registration REG + + +

+ +
+
Seats5
+
TransmissionManual
+
FuelPetrol
+
TypeSUV
+
+ +
+ AC +
+ +
+
+
[[${currencySymbol}]]0
per hour +
+
+
[[${currencySymbol}]]2000
per day +
+
+
[[${currencySymbol}]]0
per month +
+
+ +
+ Est. [[${currencySymbol}]]0 + for days +
+ +
+ Self-drive + Book with driver +

+ (No drivers available right now — self-drive only.) +

+
+ Login to book + Not available +
+
+ + +
+
+

Reviews

+
+ 4.5 +
+
+ 12 reviews +
+
+
+ +
+
+ + +
+ +

Write a review

+
+ + +
+ + + +
+ + + +
+
+ No reviews yet — be the first to review this car. +
+
+
A
+
+
+ Customer + +
+

Great car!

+ 1 Jan 2026 +
+
+
+
+
+
+ + + +
+ + diff --git a/src/main/resources/templates/cars/list.html b/src/main/resources/templates/cars/list.html new file mode 100644 index 0000000..b319049 --- /dev/null +++ b/src/main/resources/templates/cars/list.html @@ -0,0 +1,101 @@ + + + + + + +
+
+
+

Available Cars

+

dates

+

All vehicles currently in service

+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +

+ drivers + No drivers currently available — you can still self-drive. +

+ +
+ No cars match your search. Reset filters → +
+ +
+
+
+ + C +
+
+ SUV +

Car name

+
+ 5 seats + Manual + Petrol +
+
+ AC +
+
+
+ + [[${currencySymbol}]]0 + / days + + + [[${currencySymbol}]]2000 / day + +
+ View +
+
+
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/driver/dashboard.html b/src/main/resources/templates/driver/dashboard.html new file mode 100644 index 0000000..f65d041 --- /dev/null +++ b/src/main/resources/templates/driver/dashboard.html @@ -0,0 +1,93 @@ + + + + + + +
+
+ +
+ + +
+
+
+ photo + RK +
+
+

Driver

+
+ AVAILABLE + Standard + ⚠ LICENCE EXPIRED +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
📍
0
Total Trips
+
📅
0
Upcoming
+
💰
[[${currencySymbol}]]
Daily Charge
+
+ + +
+

Upcoming Trips

+ + + + + + + + + + + + + +
IDCustomerCarPickupReturnRouteStatus
#1namecardatedateroutePENDING
+
No upcoming trips right now.
+
+ + +
+

Recent Trips

+ + + + + + + + + + + + +
IDCustomerCarPickupReturnStatus
#1namecardatedatePENDING
+
No trips recorded yet.
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/driver/login.html b/src/main/resources/templates/driver/login.html new file mode 100644 index 0000000..6a5c5c9 --- /dev/null +++ b/src/main/resources/templates/driver/login.html @@ -0,0 +1,54 @@ + + + + + +
+ + + + + +
+
🚗
+

Sign in

+

Access your driver dashboard

+ +
Invalid credentials or not authorised
+
You have been signed out
+ +
+
+ + +
+
+ + +
+ +
+ +

+ ← Back to main site +

+
+ +
+ + + diff --git a/src/main/resources/templates/driver/profile.html b/src/main/resources/templates/driver/profile.html new file mode 100644 index 0000000..be07e81 --- /dev/null +++ b/src/main/resources/templates/driver/profile.html @@ -0,0 +1,79 @@ + + + + + + +
+
+ +
+
+ + +
+
+
+ photo + RK +
+
+

Driver

+
+ AVAILABLE + EMPLOYED +
+
+
+
+ +
+ +
+

My Details

+
Phone
+
Licence No.DL
+
Licence Expiry
+
CategoryStandard
+
Experience0 yrs
+
Daily Charge[[${currencySymbol}]]
+

To update these details, contact the office.

+
+ + +
+

My Documents

+
No documents on file.
+
+
+
+ LICENCE + EXPIRED + EXPIRING + OK +
+
Issued · Expires
+ View file ↗ +
+
+
+
+ + +
+

Change Password

+
+
+
+ +
+
+ +
+
+ +
+ + diff --git a/src/main/resources/templates/driver/trips.html b/src/main/resources/templates/driver/trips.html new file mode 100644 index 0000000..021781e --- /dev/null +++ b/src/main/resources/templates/driver/trips.html @@ -0,0 +1,34 @@ + + + + + + +
+
+ + +
+ + + + + + + + + + + + + + +
IDCustomerCarPickupReturnRouteTotalStatus
#1namecardatedateroute[[${currencySymbol}]]0PENDING
+
No trips assigned to you yet.
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html new file mode 100644 index 0000000..fd8d136 --- /dev/null +++ b/src/main/resources/templates/fragments/layout.html @@ -0,0 +1,438 @@ + + + + + + MV Rent + + + + + + + + +
+
+
+ + +
+ user + My Bookings + Admin +
+ +
+ Login + Sign Up +
+
+
+ +
+ + +
+ + + + +
+ +

Dashboard

+
+
+ +
+
+ AD +
+
Admin
+
email
+ ADMIN +
+
+ 👤 My Profile + Settings + 🌐 View Site +
+ +
+
+
+
+
+ + +
+ + +
+ + +
+
+
+ + Vendor + +
+
+ +
+
+ V +
+
Vendor
+
email
+ VENDOR +
+
+ 📊 Settlements + 🌐 Main Site +
+ +
+
+
+
+
+
+ +
+ + +
+
+
+ + Driver + +
+
+ +
+
+ D +
+
Driver
+
email
+ DRIVER +
+
+ 👤 My Profile + 📍 My Trips +
+ +
+
+
+
+
+
+ +
+ + +
+
+ + +
+ user + My Bookings + Admin +
+ +
+ Login + Sign Up +
+
+
+ + + + + + + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..a81683c --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,334 @@ + + + + + +
+ + +
+
+
+
+
+
+
+
+
+ + + + + +
+
+ + +
+
+
+ +
+ + + +
+ + +
+ Category + + + + + + + +
+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + + + +
+
+ Duration + + + + +
+ + + + +
+ + + + + + +
+
+
+ + +
+
+
+

Why Choose Us

+

Everything you need for a hassle-free drive

+
+
+
🚚

Doorstep Delivery

+
🔧

Zero Maintenance

+
📍

Multiple Pickup

+
🚨

Roadside Assistance

+
🛡

Affordable Insurance

+
🎁

Offers & Discounts

+
+
+
+ + +
+
+
+

Special Offers

+

Drive more, pay less

+
+
+
App offer
+
Offer 1
+
Offer 2
+
+
+
+ + +
+
+
+

Featured Vehicles

+

Choose from our premium fleet

+
+ +
+ No cars in the fleet yet. +
+ +
+
+
+ + C +
+
+

Volkswagen Virtus

+
★★★★★
+
+ Automatic + Diesel + 5 Seater +
+
+
+ [[${currencySymbol}]]2,530 + /day +
+ Rent Now +
+
+
+
+ + +
+
+ + +
+
+
+
+ Mobile App +

MV Rent Mobile App

+

Book a car in seconds, manage trips on the go.

+ Download Now → +
+
+ FAQs +

More About Us

+

Curious how it works? Browse our most common questions.

+ View FAQs → +
+
+ Blog +

Get Inspired

+

Travel guides, road trip ideas, and driving tips.

+ Read Blogs → +
+
+
+
+ + +
+
+
+

What Our Customers Say

+

Real stories from happy drivers

+
+
+
+ +

A smooth, reliable rental experience. The pickup was on time and the car was spotless. Recommended for anyone exploring Kerala.

+
H
Harish K.
Travel Guide
+
+
+ +

Booked through the app, picked up the car at the airport, and we were on the road in minutes. Will book again.

+
S
Saiprasad R.
Avid Traveller
+
+
+ +

Great pricing and a wide choice of cars. The team helped me with a last-minute change with no hassle.

+
L
Lerin Z.
Customer
+
+
+ +

Spotless vehicles, fair prices, and professional staff. Five stars for my trip across the backwaters.

+
R
Ram Kishore
Customer
+
+
+
+
+ + + + + +
+
+

Rent a Car from the Best

+

Looking for a self-drive car rental? We offer a flexible, affordable way to explore South India on your own schedule — no hidden fees, no surprises.

+

Pick from a fleet of well-maintained hatchbacks, sedans, SUVs and premium cars. Book online in minutes, get the car delivered to your doorstep, and drive wherever you like.

+

With branches across Kerala, Tamil Nadu and Karnataka, you're never far from a pickup point. Round-the-clock roadside assistance keeps you covered for the entire trip.

+
+
+ + +
+
+
+

Self Drive Cars Near You

+
+
+
🚗
Self Drive Car in Kerala
+
🚗
Self Drive Car in Kochi
+
🚗
Self Drive Car in Thrissur
+
🚗
Self Drive Car in Kottayam
+
🚗
Self Drive Car in Alappuzha
+
🚗
Self Drive Car in Trivandrum
+
🚗
Self Drive Car in Quilon
+
🚗
Self Drive Car in Varkala
+
🚗
Self Drive Car in Calicut
+
🚗
Self Drive Car in Kannur
+
+
+
+ +
+ + + + + diff --git a/src/main/resources/templates/pdf/invoice.html b/src/main/resources/templates/pdf/invoice.html new file mode 100644 index 0000000..2fa0b2e --- /dev/null +++ b/src/main/resources/templates/pdf/invoice.html @@ -0,0 +1,75 @@ + + + + + + + +
+
MV Rent
+
INVOICE
+
+ + + + + + +
+ Billed To:
+ name
+ email +
+ Invoice: INV-00001
+ Issued: date
+ Booking: #1 +
+ +

+ Vehicle: car · + dates · + 1 day
+ Route: loc +

+ + + + + + + + + + + + + + + + + + + + + + + +
DescriptionAmount
Vehicle₹0
Driver₹0
Tax₹0
Total₹0
+ +
+ Status: PENDING · Thank you for choosing MV Rent. +
+ + diff --git a/src/main/resources/templates/pdf/report-bookings.html b/src/main/resources/templates/pdf/report-bookings.html new file mode 100644 index 0000000..b5c5157 --- /dev/null +++ b/src/main/resources/templates/pdf/report-bookings.html @@ -0,0 +1,56 @@ + + + + + + + +
+
MV Rent
+
BOOKINGS REPORT
+
+ + + + + + + + + + + + + + + + + + + + + +
IDCustomerCarDriverOwnerPickupReturnVehicleTaxTotalStatus
1namecarSelfOWNEDdatedate000PENDING
No bookings match the filters.
+ + + + + + + + +
Bookings0
Gross (vehicle)0
Driver charges0
Tax0
Commission0
Total0
+ + diff --git a/src/main/resources/templates/pdf/settlement.html b/src/main/resources/templates/pdf/settlement.html new file mode 100644 index 0000000..ea1ff76 --- /dev/null +++ b/src/main/resources/templates/pdf/settlement.html @@ -0,0 +1,63 @@ + + + + + + + +
+
MV Rent
+
VENDOR SETTLEMENT STATEMENT
+
+ +

+ Vendor: vendor
+ Period: period
+ Status: DRAFT · + Generated: date +

+ + + + + + + +
Trips settled0
Gross rental revenue₹0
Less: organization commission₹0
Add: vendor driver charges₹0
Net payable to vendor₹0
+ + + + + + + + + + + + + + + + + +
BookingCarPickupReturnVehicleCommissionDriver
#1cardatedate₹0₹0₹0
No trips in this period.
+ +
+ This is a system-generated settlement statement from MV Rent. +
+ + diff --git a/src/main/resources/templates/vendor/cars.html b/src/main/resources/templates/vendor/cars.html new file mode 100644 index 0000000..6ae8b30 --- /dev/null +++ b/src/main/resources/templates/vendor/cars.html @@ -0,0 +1,41 @@ + + + + + + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + +
IDReg NoMake / ModelTypePrice/dayStatus
1REGnameSUV[[${currencySymbol}]]2000ACTIVE
No cars attached.
+
+
+
+ + + + +
+ + diff --git a/src/main/resources/templates/vendor/dashboard.html b/src/main/resources/templates/vendor/dashboard.html new file mode 100644 index 0000000..2e7dced --- /dev/null +++ b/src/main/resources/templates/vendor/dashboard.html @@ -0,0 +1,87 @@ + + + + + + +
+
+ +
+
+

Vendor

+

Your cars, drivers, trips & earnings at a glance

+
+
+
Net Payable
+
[[${currencySymbol}]]0
+
+
+ +
+
🚗
0
My Cars
+
👤
0
My Drivers
+
📍
0
Total Trips
+
💰
[[${currencySymbol}]]0
Gross Revenue
+
+ +
+
+

Earnings breakdown

+ +
+
+

Summary

+ + + + + + + +
Gross rental revenue[[${currencySymbol}]]0
Less: organization commission- [[${currencySymbol}]]0
Net payable to you[[${currencySymbol}]]0
Settled trips0
+ View settlements → +
+
+ +
+

Recent Trips

+ + + + + + + + + + + + + + +
IDCarCustomerPickupReturnAmountStatus
#1carnamedatedate[[${currencySymbol}]]0PENDING
No trips yet.
+
+
+
+ + + + +
+ + diff --git a/src/main/resources/templates/vendor/drivers.html b/src/main/resources/templates/vendor/drivers.html new file mode 100644 index 0000000..72adc28 --- /dev/null +++ b/src/main/resources/templates/vendor/drivers.html @@ -0,0 +1,41 @@ + + + + + + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + +
IDNamePhoneLicenceDay ChargeStatus
1nameDL[[${currencySymbol}]]800AVAILABLE
No drivers attached.
+
+
+
+ + + + +
+ + diff --git a/src/main/resources/templates/vendor/login.html b/src/main/resources/templates/vendor/login.html new file mode 100644 index 0000000..c099532 --- /dev/null +++ b/src/main/resources/templates/vendor/login.html @@ -0,0 +1,54 @@ + + + + + +
+ + + + + +
+
🏢
+

Sign in

+

Access your vendor dashboard

+ +
Invalid credentials or not authorised
+
You have been signed out
+ +
+
+ + +
+
+ + +
+ +
+ +

+ ← Back to main site +

+
+ +
+ + + diff --git a/src/main/resources/templates/vendor/settlements.html b/src/main/resources/templates/vendor/settlements.html new file mode 100644 index 0000000..4a40867 --- /dev/null +++ b/src/main/resources/templates/vendor/settlements.html @@ -0,0 +1,52 @@ + + + + + + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + +
IDPeriodTripsGrossCommissionDriver ChargesNet PayableStatusExport
1period0[[${currencySymbol}]]0[[${currencySymbol}]]0[[${currencySymbol}]]0[[${currencySymbol}]]0 + DRAFT + + PDF + Excel +
No settlements yet.
+
+
+
+ + + + +
+ + diff --git a/src/main/resources/templates/vendor/trips.html b/src/main/resources/templates/vendor/trips.html new file mode 100644 index 0000000..f10e57e --- /dev/null +++ b/src/main/resources/templates/vendor/trips.html @@ -0,0 +1,47 @@ + + + + + + +
+
+ + +
+

Trips

0
+

Gross

[[${currencySymbol}]]0
+

Commission

[[${currencySymbol}]]0
+

Net

[[${currencySymbol}]]0
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + +
IDCarCustomerPickupReturnAmountStatus
1carnamedatedate[[${currencySymbol}]]0PENDING
No trips yet.
+
+
+
+ + + + + + +