redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s

This commit is contained in:
root
2026-06-26 16:27:21 -04:00
parent 256ff0814e
commit d7fb7b7a7b
1030 changed files with 107374 additions and 2657 deletions
+697
View File
@@ -0,0 +1,697 @@
# Dashboard App
This document explains the purpose, structure, runtime behavior, and main feature flows of the dashboard application in `apps/dashboard`.
Source of truth:
- `apps/dashboard/src/app/*`
- `apps/dashboard/src/components/*`
- `apps/dashboard/src/lib/*`
- `apps/dashboard/src/hooks/*`
- `apps/dashboard/src/middleware.ts`
- `apps/dashboard/next.config.js`
## Purpose
The dashboard is the internal company workspace for RentalDriveGo. It is the private B2B interface used by rental-company staff to run day-to-day operations.
The app covers:
- employee sign-in
- onboarding after company creation
- fleet management
- reservations and rental lifecycle operations
- online reservation intake
- customer CRM
- contracts and billing
- subscription management
- reviews, complaints, notifications, and settings
It is distinct from:
- the marketplace app, which is public and cross-company
- the company site app, which is public and company-branded
- the admin app, which is platform-internal rather than tenant-scoped
## Tech and Runtime
- framework: Next.js 14 App Router
- ui: React 18 + Tailwind CSS
- charts: Recharts
- realtime notifications: `socket.io-client`
- shared types: `@rentaldrivego/types`
- default dev port: `3001`
- base path: `/dashboard`
Defined in `package.json`:
- `npm run dev --workspace @rentaldrivego/dashboard`
- `npm run build --workspace @rentaldrivego/dashboard`
- `npm run start --workspace @rentaldrivego/dashboard`
## URL and Deployment Model
The app is designed to run under `/dashboard`, not at the domain root.
Key behavior from `next.config.js`:
- `basePath` is `/dashboard`
- browser API calls are proxied through `/dashboard/api/*`
- those calls are rewritten to the API origin configured by `API_INTERNAL_URL` or `API_URL`
- image loading allows Cloudinary plus `/storage/*` assets from the API host
- `DASHBOARD_ASSET_PREFIX` can force absolute JS/CSS asset URLs when the dashboard is served behind another frontend proxy
This lets the dashboard run:
- directly in dev on port `3001`
- behind a reverse proxy
- embedded under a shared marketplace/admin hostname while still loading its own chunks correctly
## Top-Level Structure
Main folders:
```text
apps/dashboard/
src/app/
(dashboard)/ private employee workspace
(public)/ public shell pages
onboarding/ post-signup setup
sign-in/ employee sign-in
sign-up/ company signup
forgot-password/
reset-password/
src/components/
layout/ sidebar, topbar, public shell
reservations/ inspections, photo flows, condition sheet
team/ invite/edit modals and permissions matrix
ui/ shared view components
src/hooks/
useTeam.ts team management data/actions
src/lib/
api.ts cookie-aware API fetch wrapper
preferences.ts language/theme persistence scoped per employee
dashboardPaths.ts normalizes basePath-aware routes
urls.ts resolves marketplace/admin app links
src/middleware.ts auth gate and redirect logic
```
## Rendering Strategy
Most dashboard screens are client components.
That is a deliberate fit for the app because the dashboard relies heavily on:
- HttpOnly session-cookie authentication
- cookie and embedded-host redirects
- optimistic UI updates
- direct REST fetches after mount
- websocket notification updates
- large interactive forms and modal-heavy workflows
The root `src/app/layout.tsx` is still server-rendered enough to:
- read the shared language cookie
- set `lang` and `dir`
- inject a theme bootstrapping script before hydration
## Auth and Session Model
The dashboard uses API-issued employee sessions stored in an HttpOnly cookie.
### Login flow
`src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`:
1. submits credentials to `POST /auth/employee/login`
2. receives an HttpOnly `employee_session` cookie from the API
3. stores only non-sensitive employee profile/preferences client-side
4. redirects to the requested dashboard path
The sign-in page also supports:
- language and theme query params
- embedded usage through `postMessage`
- redirect handoff to the admin app after the API sets the admin HttpOnly session cookie
### Protected-route middleware
`src/middleware.ts` protects the app.
Important behavior:
- public dashboard routes are limited to sign-in and forgot-password flows
- all other `/dashboard/*` routes require the `employee_session` cookie
- unauthenticated users are redirected either to the marketplace root or to `/dashboard/sign-in`, depending on host context
- duplicate `/dashboard/dashboard` paths are normalized back to `/dashboard`
This means route protection is enforced before React renders the protected pages.
### Role gating
The dashboard does not depend only on route middleware. It also performs UI-level role gating.
Examples:
- the sidebar filters navigation items by employee role
- subscription access is restricted to `OWNER`
- manager/owner-only actions are hidden or disabled in feature pages
Role rank used in the shell:
- `OWNER`
- `MANAGER`
- `AGENT`
## Shell and Navigation
The private shell is defined by:
- `src/app/(dashboard)/layout.tsx`
- `src/components/layout/Sidebar.tsx`
- `src/components/layout/TopBar.tsx`
### Sidebar
Responsibilities:
- tenant branding fetch from `/companies/me/brand`
- current employee profile fetch from `/auth/employee/me`
- role-based navigation filtering
- language and theme controls
- logout handling
- communication with a parent frame when embedded
Main sections exposed in navigation:
- dashboard home
- fleet
- reservations
- online reservations
- customers
- offers
- team
- reports
- subscription
- billing
- contracts
- reviews
- complaints
- notifications
- settings
### Top bar
Responsibilities:
- current page title resolution based on normalized dashboard path
- unread notification count
- compact inbox dropdown
- websocket subscription to realtime notification events
Notification reads are synchronized through the API and local UI events.
## Shared State: Language and Theme
The dashboard uses `DashboardI18nProvider` in `src/components/I18nProvider.tsx`.
It handles:
- current language: `en`, `fr`, `ar`
- current theme: `light`, `dark`
- full in-app copy dictionaries
- persistence to cookies and `localStorage`
- per-employee scoped preferences using non-sensitive cached profile metadata
`src/lib/preferences.ts` is the key utility here. It stores both:
- shared values like `rentaldrivego-language`
- employee-scoped variants so one staff member does not override another on the same device
Arabic also flips layout direction through the root layout by setting `dir="rtl"`.
## Data Fetching Conventions
The dashboard centralizes API access in `src/lib/api.ts`.
### `apiFetch`
Browser fetch behavior:
- never reads authentication tokens from `localStorage`
- sends authenticated requests with the HttpOnly session cookie
- defaults JSON requests to `Content-Type: application/json`
- preserves `FormData` for file uploads
- includes credentials for cookie-aware flows
- unwraps `{ data }` responses automatically
Browser base URL:
- `NEXT_PUBLIC_API_URL`
- fallback `/dashboard/api/v1`
### `apiFetchServer`
Server-side helper for routes/components that explicitly receive a trusted server-side token and need `cache: 'no-store'`. Browser code should use HttpOnly cookies instead.
### Pattern used across pages
Most pages follow the same client-side pattern:
1. mount
2. call one or more `apiFetch(...)` requests
3. store result in local component state
4. show loading and empty states
5. mutate through API actions
6. refetch or patch local state
There is intentionally very little abstraction beyond this. The code favors explicit page-level orchestration over a large client data framework.
## Feature Areas
### 1. Company signup and onboarding
Relevant routes:
- `src/app/sign-up/[[...sign-up]]/page.tsx`
- `src/app/onboarding/page.tsx`
The signup screen creates the company through:
- `POST /auth/company/signup`
The onboarding flow then completes the initial tenant setup in three steps:
1. basic company info via `PATCH /companies/me`
2. brand/public profile via `PATCH /companies/me/brand`
3. payment and marketplace settings via `PATCH /companies/me/brand`
This makes onboarding a dashboard-owned continuation of the API signup flow.
### 2. Dashboard home
Route:
- `src/app/(dashboard)/page.tsx`
Backed by:
- `GET /analytics/dashboard`
Primary contents:
- KPI cards
- source breakdown chart
- subscription-status banner
- recent reservations list
Revenue visibility is reduced for lower-privilege roles.
### 3. Fleet
Routes:
- `src/app/(dashboard)/fleet/page.tsx`
- `src/app/(dashboard)/fleet/[id]/page.tsx`
- `src/components/VehicleCalendar.tsx`
Backed by:
- `GET /vehicles`
- `POST /vehicles`
- `GET /vehicles/:id`
- `PATCH /vehicles/:id`
- `PATCH /vehicles/:id/status`
- `POST /vehicles/:id/photos`
- `DELETE /vehicles/:id/photos/:idx`
- `GET /vehicles/:id/maintenance`
- `POST /vehicles/:id/maintenance`
- `GET /vehicles/:id/calendar`
- `POST /vehicles/:id/calendar/blocks`
- `DELETE /vehicles/:id/calendar/blocks/:blockId`
Key responsibilities:
- create and edit vehicles
- upload vehicle images
- publish/unpublish vehicles
- change operational status
- record maintenance
- visualize reservations and manual blocks on a calendar
- configure pickup and dropoff location rules
### 4. Reservations
Routes:
- `src/app/(dashboard)/reservations/page.tsx`
- `src/app/(dashboard)/reservations/new/page.tsx`
- `src/app/(dashboard)/reservations/[id]/page.tsx`
Backed by:
- `GET /reservations`
- `POST /reservations`
- `GET /reservations/:id`
- `PATCH /reservations/:id`
- `POST /reservations/:id/confirm`
- `POST /reservations/:id/checkin`
- `POST /reservations/:id/checkout`
- `POST /reservations/:id/close`
- `POST /reservations/:id/extend`
- `PUT /reservations/:id/inspections/:type`
- `PATCH /reservations/:id/additional-drivers/:driverId/approval`
- `GET|POST|DELETE /reservations/:id/photos*`
This is the heaviest operational area of the dashboard.
Major workflows:
- create draft bookings
- choose existing or new customers
- upload and manage customer license images
- confirm/check-in/check-out/close rentals
- edit booking vs return data based on reservation workflow state
- complete check-in/check-out inspections
- upload pickup and dropoff photos
- extend rentals
- approve additional drivers
Supporting reservation UI components:
- `DamageInspectionCard.tsx`
- `ReservationPhotoSection.tsx`
- `VehicleConditionSheet.tsx`
### 5. Online reservations
Route:
- `src/app/(dashboard)/online-reservations/page.tsx`
Backed by:
- `GET /reservations?source=MARKETPLACE&status=DRAFT`
- `POST /reservations/:id/confirm`
- `POST /reservations/:id/cancel`
This page is the triage surface for new public booking requests before staff converts them into active operational reservations.
### 6. Customers
Route:
- `src/app/(dashboard)/customers/page.tsx`
Backed mainly by:
- `GET /customers`
The reservations-create flow also uses:
- `POST /customers`
- `PATCH /customers/:id`
- `POST /customers/:id/license-image`
The customer surface acts as a company-local CRM rather than a global renter account manager.
### 7. Offers
Route:
- `src/app/(dashboard)/offers/page.tsx`
Backed by:
- `GET /offers`
- `GET /offers/:id`
- `POST /offers`
- `PATCH /offers/:id`
- `DELETE /offers/:id`
- `GET /vehicles`
Purpose:
- create and edit discounts
- target specific vehicles
- manage promo details and active/public state
### 8. Team
Route:
- `src/app/(dashboard)/team/page.tsx`
Supporting hook and components:
- `src/hooks/useTeam.ts`
- `components/team/InviteModal.tsx`
- `components/team/EditMemberModal.tsx`
- `components/team/PermissionsMatrix.tsx`
Backed by:
- `GET /team`
- `GET /team/stats`
- `POST /team/invite`
- `PATCH /team/:id/role`
- `POST /team/:id/deactivate`
- `POST /team/:id/reactivate`
- `DELETE /team/:id`
This area centralizes employee lifecycle management.
### 9. Reports
Route:
- `src/app/(dashboard)/reports/page.tsx`
Backed by:
- `GET /analytics/report?period=...`
Purpose:
- revenue and operational analytics
- summarized reporting views
### 10. Subscription
Route:
- `src/app/(dashboard)/subscription/page.tsx`
Backed by:
- `GET /subscriptions/me`
- `GET /subscriptions/invoices`
- `GET /subscriptions/providers`
- `GET /subscriptions/plans`
- `GET /subscriptions/features`
- `POST /subscriptions/checkout`
- `POST /subscriptions/cancel`
- `POST /subscriptions/resume`
This is owner-only and manages the SaaS relationship between the company and RentalDriveGo.
Key functions:
- current plan visibility
- plan comparison
- provider availability checks
- checkout redirection
- invoice history
- cancel/resume lifecycle
### 11. Billing
Route:
- `src/app/(dashboard)/billing/page.tsx`
Backed by:
- `GET /reservations`
- `GET /payments/company`
- `POST /payments/reservations/:id/manual`
This page handles customer-side rental billing, not SaaS subscription billing.
It focuses on:
- reservation invoice state
- collected vs outstanding balances
- payment history
- manual payment recording
- quick jumps to reservation and contract pages
### 12. Contracts
Routes:
- `src/app/(dashboard)/contracts/page.tsx`
- `src/app/(dashboard)/contracts/[id]/page.tsx`
Backed by:
- `GET /reservations`
- `GET /reservations/:id/contract`
Purpose:
- list reservations with contract/invoice state
- generate and reopen rental-contract views
- present printable contract payloads for staff operations
### 13. Reviews and complaints
Routes:
- `src/app/(dashboard)/reviews/page.tsx`
- `src/app/(dashboard)/complaints/page.tsx`
Backed by:
- `GET /reviews`
- `GET /reviews/stats`
- `PATCH /reviews/:id/reply`
- `GET /complaints`
- `POST /complaints`
- `PATCH /complaints/:id`
These pages give staff post-rental quality and dispute management tools.
### 14. Notifications
Route:
- `src/app/(dashboard)/notifications/page.tsx`
Backed by:
- `GET /notifications/company`
- `GET /notifications/history`
- `GET /notifications/company/preferences`
- `POST /notifications/company/:id/read`
- `POST /notifications/company/read-all`
- `PATCH /notifications/company/preferences`
This page complements the top-bar notification dropdown by adding:
- full inbox view
- delivery history audit
- per-event, per-channel preference management
### 15. Settings
Route:
- `src/app/(dashboard)/settings/page.tsx`
Backed by:
- `GET|PATCH /companies/me/brand`
- `POST /companies/me/brand/logo`
- `POST /companies/me/brand/hero`
- `POST|DELETE /companies/me/brand/custom-domain`
- `GET|PATCH /companies/me/contract-settings`
- `GET|POST|PATCH /companies/me/insurance-policies`
- `GET|POST|PATCH /companies/me/pricing-rules`
- `GET|PATCH /companies/me/accounting-settings`
This is the broad configuration surface for the tenant.
It includes:
- public brand and marketplace profile
- payment account identifiers
- custom domain setup
- rental policies
- insurance products
- pricing rules
- accounting defaults
## Public Shell Pages
Public-facing dashboard pages live outside the private shell.
Notable routes:
- `/dashboard/sign-in`
- `/dashboard/sign-up`
- `/dashboard/forgot-password`
- `/dashboard/reset-password`
- `/dashboard/onboarding`
Layout:
- `src/app/(public)/layout.tsx`
- `src/components/layout/PublicShell.tsx`
- `src/components/layout/PublicHeader.tsx`
- `src/components/layout/PublicFooter.tsx`
These pages share the branding system and language/theme controls but do not render the private sidebar/topbar shell.
## Embedded and Cross-App Behavior
The dashboard supports embedded scenarios.
Examples in the code:
- sign-in posts messages to the parent frame
- logout posts an employee-logout event
- current embedded path is reported upward
It also coordinates with sibling apps:
- `marketplaceUrl` links staff back to the public marketplace domain
- `adminUrl` supports cookie-based handoff into the admin interface
- host-aware URL rewriting allows the same app to function under external domains, internal Docker hostnames, and proxied environments
## API Dependency Summary
The dashboard is a thin client over the company API. Its main backend dependencies are:
- auth: employee login and profile
- companies: brand, contract settings, pricing rules, accounting, domains
- vehicles: fleet CRUD, photos, maintenance, availability calendar
- reservations: booking lifecycle and inspections
- customers: CRM and license documents
- offers: promotions
- team: employee management
- analytics: dashboard and reports
- notifications: inbox, history, preferences
- subscriptions: SaaS plan management
- payments: rental payment recording
- reviews and complaints: post-rental follow-up
The frontend does not try to reproduce business rules locally. It delegates workflow authority to the API and reflects the resulting state.
## Design Characteristics
The current dashboard code favors:
- explicit page-local state over a global data layer
- direct REST integration over generated clients
- strong tenant scoping through backend auth rather than frontend assumptions
- rich operational forms over minimal CRUD scaffolds
- multilingual support directly in the app shell
Tradeoffs of this approach:
- easy to reason about per page
- low indirection when debugging API interactions
- more repeated fetch/state patterns than a centralized query layer would have
- large single-file page components in the heaviest screens
## Recommended Maintenance Rules
When changing the dashboard, keep these rules in mind:
1. Preserve the `/dashboard` base path and `toDashboardAppPath` normalization behavior.
2. Keep route protection aligned with `src/middleware.ts`.
3. If a new page depends on employee role, gate it both in navigation and in-page behavior.
4. Reuse `apiFetch` for auth and error handling consistency.
5. Keep language/theme strings inside the dashboard i18n system rather than scattering literals.
6. Update this README when a new major page, workflow, or integration pattern is added.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+111
View File
@@ -0,0 +1,111 @@
/** @type {import('next').NextConfig} */
const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
const toStoragePattern = (rawUrl) => {
try {
const u = new URL(rawUrl.replace(/\/api\/v1\/?$/, ''))
return {
protocol: u.protocol.replace(':', ''),
hostname: u.hostname,
...(u.port ? { port: u.port } : {}),
pathname: '/storage/**',
}
} catch {
return null
}
}
const storagePatterns = [
process.env.API_INTERNAL_URL ?? 'http://localhost:4000',
process.env.API_URL,
process.env.NEXT_PUBLIC_API_URL,
]
.filter(Boolean)
.map(toStoragePattern)
.filter(Boolean)
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i)
const DASHBOARD_BASE_PATH = '/dashboard'
// basePath is required so the client-side router knows it's mounted at /dashboard.
// Without it, React cannot hydrate because the URL (/dashboard/sign-up) doesn't
// match the route (/sign-up). The Turbopack double-basePath prefetch bug is
// handled by the marketplace middleware (307 redirect /dashboard/dashboard → /dashboard).
//
// In local development the dashboard is often viewed through the marketplace
// proxy on port 3000 while the dashboard dev server runs on port 3001. Next
// rewrites do not reliably proxy HMR WebSocket upgrades, so emit absolute
// dashboard asset/HMR URLs directly to the dashboard dev server.
const dashboardAssetPrefix = process.env.DASHBOARD_ASSET_PREFIX
?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined)
const assetPrefix = normalizeAssetPrefix(dashboardAssetPrefix, DASHBOARD_BASE_PATH)
const securityHeaders = buildSecurityHeaders({
assetSources: [assetPrefix].filter(Boolean),
})
const nextConfig = {
basePath: DASHBOARD_BASE_PATH,
...(assetPrefix ? { assetPrefix } : {}),
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'res.cloudinary.com',
},
...storagePatterns,
],
},
transpilePackages: ['@rentaldrivego/types'],
async headers() {
return [
{
source: '/_next/static/media/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
],
},
{
source: '/:path*',
headers: securityHeaders,
},
]
},
async redirects() {
return [
{
source: `${DASHBOARD_BASE_PATH}/:path*`,
destination: '/:path*',
permanent: false,
},
{
source: DASHBOARD_BASE_PATH,
destination: '/',
permanent: false,
},
{
source: '/',
destination: DASHBOARD_BASE_PATH,
permanent: false,
basePath: false,
},
]
},
async rewrites() {
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
return [
{
source: '/api/:path*',
destination: `${apiOrigin}/api/:path*`,
},
{
source: '/storage/:path*',
destination: `${apiOrigin}/storage/:path*`,
},
]
},
}
module.exports = nextConfig
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@rentaldrivego/dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"predev": "npm run build --workspace @rentaldrivego/types",
"dev": "next dev -H 0.0.0.0 -p 3001",
"prebuild": "npm run build --workspace @rentaldrivego/types",
"build": "next build",
"prestart": "npm run build --workspace @rentaldrivego/types",
"pretype-check": "npm run build --workspace @rentaldrivego/types",
"start": "next start -H 0.0.0.0 -p 3001",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@rentaldrivego/types": "*",
"autoprefixer": "^10.4.19",
"dayjs": "^1.11.11",
"lucide-react": "^0.376.0",
"next": "^16.2.7",
"postcss": "^8.4.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.12.7",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.5",
"tailwindcss": "^3.4.3",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^20.12.0",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.0",
"vitest": "^1.6.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

@@ -0,0 +1,644 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { formatCurrency, SupportedCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type ReservationRow = {
id: string
invoiceNumber: string | null
contractNumber: string | null
status: string
paymentStatus: string
startDate: string
endDate: string
totalAmount: number
depositAmount: number
paidAmount: number
customer: { firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; licensePlate: string }
}
type PaymentRow = {
id: string
reservationId: string
amount: number
currency: SupportedCurrency
status: string
type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL'
paymentMethod: string | null
paidAt: string | null
createdAt: string
}
type BillingRow = ReservationRow & {
balanceDue: number
paymentCount: number
latestPayment: PaymentRow | null
}
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL'
const PAYMENT_STATUS_BADGE: Record<string, string> = {
UNPAID: 'bg-rose-100 text-rose-700',
PAID: 'bg-emerald-100 text-emerald-700',
PARTIAL: 'bg-orange-100 text-orange-700',
PENDING: 'bg-sky-100 text-sky-700',
REFUNDED: 'bg-slate-100 text-slate-700',
FAILED: 'bg-rose-100 text-rose-700',
OVERDUE: 'bg-orange-100 text-orange-700',
}
const PAYMENT_EVENT_BADGE: Record<string, string> = {
PENDING: 'bg-orange-100 text-orange-700',
SUCCEEDED: 'bg-emerald-100 text-emerald-700',
FAILED: 'bg-rose-100 text-rose-700',
REFUNDED: 'bg-slate-100 text-slate-700',
PARTIALLY_REFUNDED: 'bg-sky-100 text-sky-700',
}
export default function BillingPage() {
const { language } = useDashboardI18n()
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservations, setReservations] = useState<ReservationRow[]>([])
const [payments, setPayments] = useState<PaymentRow[]>([])
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
const paymentCurrency = 'MAD'
const [paymentAmount, setPaymentAmount] = useState('')
const [submittingPayment, setSubmittingPayment] = useState(false)
const [paymentError, setPaymentError] = useState<string | null>(null)
const copy = {
en: {
heading: 'Customer Billing',
subtitle: 'Manage customer invoices, payment status, collected amounts, and remaining balances.',
search: 'Search by customer, invoice, contract, vehicle, or plate…',
totalBilled: 'Total billed',
totalCollected: 'Collected',
outstanding: 'Outstanding',
openInvoices: 'Open invoices',
customer: 'Customer',
vehicle: 'Vehicle',
invoice: 'Invoice',
rentalPeriod: 'Rental period',
total: 'Total',
paid: 'Paid',
balance: 'Balance',
status: 'Status',
payment: 'Payment',
paymentMethod: 'Payment method',
actions: 'Actions',
contract: 'Contract',
deposit: 'Deposit',
acceptPayment: 'Accept payment',
paymentModalTitle: 'Accept customer payment',
paymentModalSubtitle: 'Record a payment received for this reservation. No online provider setup is required.',
paymentType: 'Payment type',
paymentCurrency: 'Payment currency',
paymentAmount: 'Payment amount',
cancel: 'Cancel',
savingPayment: 'Saving payment…',
savePayment: 'Save payment',
chargeHelp: 'Charge records a payment against the reservation balance.',
depositHelp: 'Deposit records money received for the refundable security deposit.',
invalidAmount: 'Enter a valid payment amount within the remaining balance.',
paymentActionDisabled: 'No balance due',
paymentsRecorded: (count: number) => `${count} payment(s)`,
none: '—',
loading: 'Loading customer billing…',
noRows: 'No customer bills found.',
failed: 'Failed to load customer billing.',
openContract: 'Open contract',
openBooking: 'Open booking',
charge: 'Charge',
depositType: 'Deposit',
unknownInvoice: 'Draft invoice',
paymentStatusLabels: {
UNPAID: 'Unpaid',
PAID: 'Paid',
PARTIAL: 'Partial',
PENDING: 'Pending',
REFUNDED: 'Refunded',
FAILED: 'Failed',
OVERDUE: 'Overdue',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'Pending',
SUCCEEDED: 'Succeeded',
FAILED: 'Failed',
REFUNDED: 'Refunded',
PARTIALLY_REFUNDED: 'Partially refunded',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'Cash',
CHECK: 'Check',
BANK_TRANSFER: 'Bank transfer',
CARD: 'Card',
PAYPAL: 'PayPal',
STRIPE: 'Card',
CREDIT_CARD: 'Card',
DEBIT_CARD: 'Card',
} as Record<string, string>,
},
fr: {
heading: 'Facturation clients',
subtitle: 'Gérez les factures clients, le statut des paiements, les montants encaissés et les soldes restants.',
search: 'Rechercher par client, facture, contrat, véhicule ou plaque…',
totalBilled: 'Total facturé',
totalCollected: 'Encaissé',
outstanding: 'Solde restant',
openInvoices: 'Factures ouvertes',
customer: 'Client',
vehicle: 'Véhicule',
invoice: 'Facture',
rentalPeriod: 'Période',
total: 'Total',
paid: 'Payé',
balance: 'Solde',
status: 'Statut',
payment: 'Paiement',
paymentMethod: 'Mode de paiement',
actions: 'Actions',
contract: 'Contrat',
deposit: 'Dépôt',
acceptPayment: 'Encaisser',
paymentModalTitle: 'Encaisser un paiement client',
paymentModalSubtitle: 'Enregistrez un paiement reçu pour cette réservation. Aucune configuration de fournisseur en ligne nest requise.',
paymentType: 'Type de paiement',
paymentCurrency: 'Devise du paiement',
paymentAmount: 'Montant du paiement',
cancel: 'Annuler',
savingPayment: 'Enregistrement…',
savePayment: 'Enregistrer le paiement',
chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.',
depositHelp: 'Le dépôt enregistre lencaissement du dépôt de garantie remboursable.',
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
paymentActionDisabled: 'Aucun solde dû',
paymentsRecorded: (count: number) => `${count} paiement(s)`,
none: '—',
loading: 'Chargement de la facturation client…',
noRows: 'Aucune facture client trouvée.',
failed: 'Échec du chargement de la facturation client.',
openContract: 'Ouvrir le contrat',
openBooking: 'Ouvrir la réservation',
charge: 'Paiement',
depositType: 'Dépôt',
unknownInvoice: 'Facture brouillon',
paymentStatusLabels: {
UNPAID: 'Non payé',
PAID: 'Payé',
PARTIAL: 'Partiel',
PENDING: 'En attente',
REFUNDED: 'Remboursé',
FAILED: 'Échoué',
OVERDUE: 'En retard',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'En attente',
SUCCEEDED: 'Réussi',
FAILED: 'Échoué',
REFUNDED: 'Remboursé',
PARTIALLY_REFUNDED: 'Partiellement remboursé',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'Espèces',
CHECK: 'Chèque',
BANK_TRANSFER: 'Virement bancaire',
CARD: 'Carte',
PAYPAL: 'PayPal',
STRIPE: 'Carte',
CREDIT_CARD: 'Carte',
DEBIT_CARD: 'Carte',
} as Record<string, string>,
},
ar: {
heading: 'فوترة العملاء',
subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.',
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…',
totalBilled: 'إجمالي الفواتير',
totalCollected: 'المحصّل',
outstanding: 'المتبقي',
openInvoices: 'الفواتير المفتوحة',
customer: 'العميل',
vehicle: 'المركبة',
invoice: 'الفاتورة',
rentalPeriod: 'فترة الإيجار',
total: 'الإجمالي',
paid: 'المدفوع',
balance: 'الرصيد',
status: 'الحالة',
payment: 'الدفعة',
paymentMethod: 'طريقة الدفع',
actions: 'الإجراءات',
contract: 'العقد',
deposit: 'العربون',
acceptPayment: 'تحصيل دفعة',
paymentModalTitle: 'تحصيل دفعة من العميل',
paymentModalSubtitle: 'سجّل دفعة مستلمة لهذا الحجز. لا يتطلب ذلك إعداد مزود دفع عبر الإنترنت.',
paymentType: 'نوع الدفع',
paymentCurrency: 'عملة الدفع',
paymentAmount: 'مبلغ الدفعة',
cancel: 'إلغاء',
savingPayment: 'جارٍ الحفظ…',
savePayment: 'حفظ الدفعة',
chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.',
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
paymentActionDisabled: 'لا يوجد رصيد مستحق',
paymentsRecorded: (count: number) => `${count} دفعة`,
none: '—',
loading: 'جارٍ تحميل فوترة العملاء…',
noRows: 'لم يتم العثور على فواتير عملاء.',
failed: 'فشل تحميل فوترة العملاء.',
openContract: 'فتح العقد',
openBooking: 'فتح الحجز',
charge: 'دفعة',
depositType: 'عربون',
unknownInvoice: 'فاتورة مسودة',
paymentStatusLabels: {
UNPAID: 'غير مدفوع',
PAID: 'مدفوع',
PARTIAL: 'جزئي',
PENDING: 'قيد الانتظار',
REFUNDED: 'مسترد',
FAILED: 'فشل',
OVERDUE: 'متأخر',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'قيد الانتظار',
SUCCEEDED: 'ناجح',
FAILED: 'فشل',
REFUNDED: 'مسترد',
PARTIALLY_REFUNDED: 'مسترد جزئياً',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'نقداً',
CHECK: 'شيك',
BANK_TRANSFER: 'تحويل بنكي',
CARD: 'بطاقة',
PAYPAL: 'PayPal',
STRIPE: 'بطاقة',
CREDIT_CARD: 'بطاقة',
DEBIT_CARD: 'بطاقة',
} as Record<string, string>,
},
}[language]
async function loadBillingData() {
const [reservationRows, paymentRows] = await Promise.all([
apiFetch<ReservationRow[]>('/reservations?pageSize=100'),
apiFetch<PaymentRow[]>('/payments/company'),
])
setReservations(reservationRows ?? [])
setPayments(paymentRows ?? [])
}
useEffect(() => {
loadBillingData()
.catch((err) => setError(err.message ?? copy.failed))
.finally(() => setLoading(false))
}, [copy.failed])
const billingRows = useMemo<BillingRow[]>(() => {
const paymentsByReservation = new Map<string, PaymentRow[]>()
for (const payment of payments) {
const existing = paymentsByReservation.get(payment.reservationId) ?? []
existing.push(payment)
paymentsByReservation.set(payment.reservationId, existing)
}
return reservations.map((reservation) => {
const reservationPayments = (paymentsByReservation.get(reservation.id) ?? []).sort((a, b) =>
new Date(b.paidAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.createdAt).getTime(),
)
return {
...reservation,
balanceDue: Math.max(reservation.totalAmount - reservation.paidAmount, 0),
paymentCount: reservationPayments.length,
latestPayment: reservationPayments[0] ?? null,
}
})
}, [payments, reservations])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return billingRows
return billingRows.filter((row) =>
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
row.customer.email.toLowerCase().includes(q) ||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
(row.invoiceNumber ?? '').toLowerCase().includes(q) ||
(row.contractNumber ?? '').toLowerCase().includes(q),
)
}, [billingRows, search])
const totals = useMemo(() => {
return filteredRows.reduce(
(acc, row) => {
acc.totalBilled += row.totalAmount
acc.totalCollected += row.paidAmount
acc.outstanding += row.balanceDue
if (row.balanceDue > 0) acc.openInvoices += 1
return acc
},
{ totalBilled: 0, totalCollected: 0, outstanding: 0, openInvoices: 0 },
)
}, [filteredRows])
function formatDate(value: string) {
return new Date(value).toLocaleDateString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function translateStatus(status: string, labels: Record<string, string>) {
return labels[status] ?? status.replace(/_/g, ' ')
}
function translatePaymentMethod(method: string | null) {
if (!method) return copy.none
return copy.paymentMethodLabels[method] ?? method.replace(/_/g, ' ')
}
function canAcceptPayment(row: BillingRow) {
return row.balanceDue > 0
}
function openPaymentModal(row: BillingRow) {
setPaymentModalRow(row)
setPaymentMethod('CASH')
setPaymentType('CHARGE')
setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
setPaymentError(null)
}
async function handleAcceptPayment() {
if (!paymentModalRow) return
const amount = Math.round(Number(paymentAmount) * 100)
if (!Number.isFinite(amount) || amount <= 0 || amount > paymentModalRow.balanceDue) {
setPaymentError(copy.invalidAmount)
return
}
setSubmittingPayment(true)
setPaymentError(null)
try {
await apiFetch<PaymentRow>(`/payments/reservations/${paymentModalRow.id}/manual`, {
method: 'POST',
body: JSON.stringify({
amount,
type: paymentType,
currency: paymentCurrency,
paymentMethod,
}),
})
await loadBillingData()
setPaymentModalRow(null)
} catch (err: any) {
setPaymentError(err.message ?? copy.failed)
} finally {
setSubmittingPayment(false)
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label={copy.totalBilled} value={formatCurrency(totals.totalBilled, 'MAD')} />
<MetricCard label={copy.totalCollected} value={formatCurrency(totals.totalCollected, 'MAD')} />
<MetricCard label={copy.outstanding} value={formatCurrency(totals.outstanding, 'MAD')} />
<MetricCard label={copy.openInvoices} value={totals.openInvoices.toLocaleString(localeCode)} />
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.rentalPeriod}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paid}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.balance}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paymentMethod}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td>
</tr>
) : filteredRows.length === 0 ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noRows}</td>
</tr>
) : filteredRows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm">
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p>{row.vehicle.make} {row.vehicle.model}</p>
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p className="font-medium text-slate-900">{row.invoiceNumber ?? copy.unknownInvoice}</p>
<p className="text-xs text-slate-500">{copy.contract}: {row.contractNumber ?? copy.none}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-600">
<p>{formatDate(row.startDate)}</p>
<p>{formatDate(row.endDate)}</p>
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(row.totalAmount, 'MAD')}
{row.depositAmount > 0 ? (
<p className="text-xs font-normal text-slate-500">{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}</p>
) : null}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-emerald-700">{formatCurrency(row.paidAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-orange-700">{formatCurrency(row.balanceDue, 'MAD')}</td>
<td className="px-6 py-4 text-sm">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_STATUS_BADGE[row.paymentStatus] ?? 'bg-slate-100 text-slate-700'}`}>
{translateStatus(row.paymentStatus, copy.paymentStatusLabels)}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{row.latestPayment ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_EVENT_BADGE[row.latestPayment.status] ?? 'bg-slate-100 text-slate-700'}`}>
{translateStatus(row.latestPayment.status, copy.paymentEventLabels)}
</span>
<span className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</span>
</div>
<p className="text-xs text-slate-500">
{row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)}
</p>
<p className="text-xs text-slate-500">{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}</p>
</div>
) : (
<span className="text-slate-400">{copy.none}</span>
)}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{row.latestPayment ? (
<div className="space-y-1">
<p className="font-medium text-slate-900">{translatePaymentMethod(row.latestPayment.paymentMethod)}</p>
<p className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</p>
</div>
) : (
<span className="text-slate-400">{copy.none}</span>
)}
</td>
<td className="px-6 py-4 text-right text-sm">
<div className="flex flex-col items-end gap-2">
{canAcceptPayment(row) ? (
<button type="button" onClick={() => openPaymentModal(row)} className="font-semibold text-emerald-700 hover:underline">
{copy.acceptPayment}
</button>
) : (
<span className="text-xs text-slate-400">{copy.paymentActionDisabled}</span>
)}
<Link href={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
{copy.openContract}
</Link>
<Link href={`/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
{copy.openBooking}
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{paymentModalRow ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm" onClick={(event) => {
if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null)
}}>
<div className="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">{copy.paymentModalTitle}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentModalSubtitle}</p>
</div>
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="text-slate-400 transition hover:text-slate-700">
×
</button>
</div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="font-semibold text-slate-900">{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}</p>
<p className="mt-1">{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}</p>
<p className="mt-1">{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}</p>
<p className="mt-2 font-semibold text-slate-900">{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}</p>
</div>
{paymentError ? (
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
{paymentError}
</div>
) : null}
<div className="mt-5 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentMethod}</label>
<select className="input-field" value={paymentMethod} onChange={(event) => setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment}>
<option value="CASH">{copy.paymentMethodLabels.CASH}</option>
<option value="CHECK">{copy.paymentMethodLabels.CHECK}</option>
<option value="BANK_TRANSFER">{copy.paymentMethodLabels.BANK_TRANSFER}</option>
<option value="CARD">{copy.paymentMethodLabels.CARD}</option>
<option value="PAYPAL">{copy.paymentMethodLabels.PAYPAL}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentType}</label>
<select className="input-field" value={paymentType} onChange={(event) => setPaymentType(event.target.value as 'CHARGE' | 'DEPOSIT')} disabled={submittingPayment}>
{paymentModalRow.depositAmount > 0 ? <option value="DEPOSIT">{copy.depositType}</option> : null}
<option value="CHARGE">{copy.charge}</option>
</select>
<p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label>
<input type="number" min="0" step="0.01" className="input-field" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} />
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="flex-1 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
{copy.cancel}
</button>
<button type="button" onClick={handleAcceptPayment} disabled={submittingPayment} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{submittingPayment ? copy.savingPayment : copy.savePayment}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="card p-5">
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="mt-2 text-2xl font-bold text-slate-900">{value}</p>
</div>
)
}
@@ -0,0 +1,567 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Complaints',
subtitle: 'Manage customer complaints and escalations',
newComplaint: 'New complaint',
filterStatus: 'Status',
filterSeverity: 'Severity',
allStatuses: 'All statuses',
allSeverities: 'All severities',
statusOpen: 'Open',
statusInvestigating: 'Investigating',
statusResolved: 'Resolved',
statusClosed: 'Closed',
sevL1: 'Level 1',
sevL2: 'Level 2',
sevL3: 'Level 3',
noComplaints: 'No complaints found',
category: 'Category',
subject: 'Subject',
reservation: 'Reservation',
date: 'Date',
status: 'Status',
severity: 'Severity',
details: 'Details',
description: 'Description',
notes: 'Notes',
resolution: 'Resolution',
updateStatus: 'Update status',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
createTitle: 'Create complaint',
fieldReservationId: 'Reservation ID (optional)',
fieldCategory: 'Category',
fieldSeverity: 'Severity',
fieldSubject: 'Subject',
fieldDescription: 'Description (optional)',
create: 'Create',
creating: 'Creating…',
errorLoad: 'Failed to load complaints',
errorCreate: 'Failed to create complaint',
errorUpdate: 'Failed to update complaint',
categories: {
BOOKING: 'Booking', PICKUP: 'Pickup', VEHICLE_CLEANLINESS: 'Vehicle Cleanliness',
VEHICLE_CONDITION: 'Vehicle Condition', STAFF_SERVICE: 'Staff Service', PRICING: 'Pricing',
DEPOSIT: 'Deposit', INSURANCE: 'Insurance', DAMAGE_CLAIM: 'Damage Claim',
RETURN_PROCESS: 'Return Process', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Roadside Assistance', BILLING: 'Billing', OTHER: 'Other',
} as Record<string, string>,
},
fr: {
heading: 'Plaintes',
subtitle: 'Gérez les plaintes et escalades clients',
newComplaint: 'Nouvelle plainte',
filterStatus: 'Statut',
filterSeverity: 'Sévérité',
allStatuses: 'Tous les statuts',
allSeverities: 'Toutes les sévérités',
statusOpen: 'Ouvert',
statusInvestigating: 'En cours',
statusResolved: 'Résolu',
statusClosed: 'Fermé',
sevL1: 'Niveau 1',
sevL2: 'Niveau 2',
sevL3: 'Niveau 3',
noComplaints: 'Aucune plainte trouvée',
category: 'Catégorie',
subject: 'Sujet',
reservation: 'Réservation',
date: 'Date',
status: 'Statut',
severity: 'Sévérité',
details: 'Détails',
description: 'Description',
notes: 'Notes',
resolution: 'Résolution',
updateStatus: 'Mettre à jour le statut',
saving: 'Enregistrement…',
save: 'Enregistrer',
cancel: 'Annuler',
createTitle: 'Créer une plainte',
fieldReservationId: 'ID de réservation (optionnel)',
fieldCategory: 'Catégorie',
fieldSeverity: 'Sévérité',
fieldSubject: 'Sujet',
fieldDescription: 'Description (optionnel)',
create: 'Créer',
creating: 'Création…',
errorLoad: 'Échec du chargement des plaintes',
errorCreate: 'Échec de la création de la plainte',
errorUpdate: 'Échec de la mise à jour de la plainte',
categories: {
BOOKING: 'Réservation', PICKUP: 'Prise en charge', VEHICLE_CLEANLINESS: 'Propreté du véhicule',
VEHICLE_CONDITION: 'État du véhicule', STAFF_SERVICE: 'Service du personnel', PRICING: 'Tarification',
DEPOSIT: 'Dépôt de garantie', INSURANCE: 'Assurance', DAMAGE_CLAIM: 'Réclamation dommages',
RETURN_PROCESS: 'Processus de retour', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Assistance routière', BILLING: 'Facturation', OTHER: 'Autre',
} as Record<string, string>,
},
ar: {
heading: 'الشكاوى',
subtitle: 'إدارة شكاوى العملاء والتصعيدات',
newComplaint: 'شكوى جديدة',
filterStatus: 'الحالة',
filterSeverity: 'الخطورة',
allStatuses: 'جميع الحالات',
allSeverities: 'جميع مستويات الخطورة',
statusOpen: 'مفتوح',
statusInvestigating: 'قيد التحقيق',
statusResolved: 'تم الحل',
statusClosed: 'مغلق',
sevL1: 'المستوى 1',
sevL2: 'المستوى 2',
sevL3: 'المستوى 3',
noComplaints: 'لا توجد شكاوى',
category: 'الفئة',
subject: 'الموضوع',
reservation: 'الحجز',
date: 'التاريخ',
status: 'الحالة',
severity: 'الخطورة',
details: 'التفاصيل',
description: 'الوصف',
notes: 'ملاحظات',
resolution: 'الحل',
updateStatus: 'تحديث الحالة',
saving: 'جار الحفظ…',
save: 'حفظ',
cancel: 'إلغاء',
createTitle: 'إنشاء شكوى',
fieldReservationId: 'رقم الحجز (اختياري)',
fieldCategory: 'الفئة',
fieldSeverity: 'الخطورة',
fieldSubject: 'الموضوع',
fieldDescription: 'الوصف (اختياري)',
create: 'إنشاء',
creating: 'جار الإنشاء…',
errorLoad: 'فشل تحميل الشكاوى',
errorCreate: 'فشل إنشاء الشكوى',
errorUpdate: 'فشل تحديث الشكوى',
categories: {
BOOKING: 'الحجز', PICKUP: 'الاستلام', VEHICLE_CLEANLINESS: 'نظافة المركبة',
VEHICLE_CONDITION: 'حالة المركبة', STAFF_SERVICE: 'خدمة الموظفين', PRICING: 'التسعير',
DEPOSIT: 'التأمين', INSURANCE: 'التأمين', DAMAGE_CLAIM: 'مطالبة الأضرار',
RETURN_PROCESS: 'عملية الإرجاع', COMMUNICATION: 'التواصل',
ROADSIDE_ASSISTANCE: 'المساعدة على الطريق', BILLING: 'الفواتير', OTHER: 'أخرى',
} as Record<string, string>,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Complaint {
id: string
companyId: string
reservationId: string | null
reviewId: string | null
customerId: string | null
severity: string
status: string
category: string
subject: string
description: string | null
resolution: string | null
notes: string | null
assignedTo: string | null
resolvedAt: string | null
createdAt: string
reservation: { id: string; startDate: string; status: string; vehicle: { make: string; model: string; year: number } } | null
customer: { id: string; firstName: string; lastName: string; email: string } | null
review: { id: string; overallRating: number } | null
}
interface ComplaintsResponse {
data: Complaint[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
]
function severityBadgeClass(sev: string) {
if (sev === 'LEVEL_3') return 'badge-red'
if (sev === 'LEVEL_2') return 'badge-amber'
return 'badge-gray'
}
function statusBadgeClass(status: string) {
if (status === 'RESOLVED' || status === 'CLOSED') return 'badge-green'
if (status === 'INVESTIGATING') return 'badge-blue'
return 'badge-amber'
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ComplaintsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [complaints, setComplaints] = useState<Complaint[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterSeverity, setFilterSeverity] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
// Create form state
const [formReservationId, setFormReservationId] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formSeverity, setFormSeverity] = useState('LEVEL_1')
const [formSubject, setFormSubject] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formCreating, setFormCreating] = useState(false)
// Edit state (for expanded complaint)
const [editStatus, setEditStatus] = useState<Record<string, string>>({})
const [editNotes, setEditNotes] = useState<Record<string, string>>({})
const [editResolution, setEditResolution] = useState<Record<string, string>>({})
const [savingId, setSavingId] = useState<string | null>(null)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterStatus) params.set('status', filterStatus)
if (filterSeverity) params.set('severity', filterSeverity)
const data = await apiFetch<ComplaintsResponse>(`/complaints?${params}`)
setComplaints(data.data ?? [])
setMeta(data.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterStatus, filterSeverity])
function toggleExpand(id: string, complaint: Complaint) {
if (expandedId === id) {
setExpandedId(null)
} else {
setExpandedId(id)
setEditStatus((prev) => ({ ...prev, [id]: complaint.status }))
setEditNotes((prev) => ({ ...prev, [id]: complaint.notes ?? '' }))
setEditResolution((prev) => ({ ...prev, [id]: complaint.resolution ?? '' }))
}
}
async function handleCreate() {
if (!formCategory || !formSubject.trim()) return
setFormCreating(true)
try {
await apiFetch('/complaints', {
method: 'POST',
body: JSON.stringify({
reservationId: formReservationId.trim() || undefined,
category: formCategory,
severity: formSeverity,
subject: formSubject.trim(),
description: formDescription.trim() || undefined,
}),
})
setShowCreate(false)
setFormReservationId('')
setFormCategory('')
setFormSeverity('LEVEL_1')
setFormSubject('')
setFormDescription('')
await loadData()
} catch {
alert(t.errorCreate)
} finally {
setFormCreating(false)
}
}
async function handleUpdate(id: string) {
setSavingId(id)
try {
await apiFetch(`/complaints/${id}`, {
method: 'PATCH',
body: JSON.stringify({
status: editStatus[id],
notes: editNotes[id] || undefined,
resolution: editResolution[id] || undefined,
}),
})
await loadData()
} catch {
alert(t.errorUpdate)
} finally {
setSavingId(null)
}
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="rdg-page-heading flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
<button onClick={() => setShowCreate((v) => !v)} className="btn-primary">
<Plus className="h-4 w-4" />
{t.newComplaint}
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-blue-950 dark:text-white">{t.createTitle}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldReservationId}</label>
<input
type="text"
value={formReservationId}
onChange={(e) => setFormReservationId(e.target.value)}
className="input-field"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldCategory}</label>
<select
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
className="input-field"
>
<option value=""> select </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{t.categories[c] ?? c}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSeverity}</label>
<select value={formSeverity} onChange={(e) => setFormSeverity(e.target.value)} className="input-field">
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSubject}</label>
<input
type="text"
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
className="input-field"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldDescription}</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={3}
className="input-field resize-none"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={formCreating || !formCategory || !formSubject.trim()}
className="btn-primary"
>
{formCreating ? t.creating : t.create}
</button>
<button onClick={() => setShowCreate(false)} className="btn-secondary">{t.cancel}</button>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterStatus}</label>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allStatuses}</option>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterSeverity}</label>
<select
value={filterSeverity}
onChange={(e) => { setFilterSeverity(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allSeverities}</option>
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
</div>
</div>
{/* List */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : complaints.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noComplaints}</div>
) : (
<div className="space-y-3">
{complaints.map((complaint) => {
const isExpanded = expandedId === complaint.id
const customerName = complaint.customer
? `${complaint.customer.firstName} ${complaint.customer.lastName}`
: null
return (
<div key={complaint.id} className="card overflow-hidden">
<button
onClick={() => toggleExpand(complaint.id, complaint)}
className="flex w-full items-start justify-between p-5 text-left hover:bg-stone-50 dark:hover:bg-blue-950/30"
>
<div className="flex flex-1 flex-wrap items-center gap-3">
<span className={severityBadgeClass(complaint.severity)}>
<AlertTriangle className="mr-1 h-3 w-3" />
{complaint.severity === 'LEVEL_1' ? t.sevL1 : complaint.severity === 'LEVEL_2' ? t.sevL2 : t.sevL3}
</span>
<span className={statusBadgeClass(complaint.status)}>
{complaint.status === 'OPEN' ? t.statusOpen
: complaint.status === 'INVESTIGATING' ? t.statusInvestigating
: complaint.status === 'RESOLVED' ? t.statusResolved
: t.statusClosed}
</span>
<span className="badge-gray">{t.categories[complaint.category] ?? complaint.category}</span>
<span className="font-medium text-blue-950 dark:text-white">{complaint.subject}</span>
{customerName && <span className="text-sm text-stone-500 dark:text-slate-400">{customerName}</span>}
{complaint.reservationId && (
<span className="font-mono text-xs text-stone-400 dark:text-slate-500">
{complaint.reservationId.slice(0, 8)}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(complaint.createdAt).toLocaleDateString()}
</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-stone-400" /> : <ChevronDown className="h-4 w-4 text-stone-400" />}
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-stone-100 p-5 dark:border-blue-900/30 space-y-4">
{complaint.description && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.description}</p>
<p className="text-sm text-stone-700 dark:text-slate-300">{complaint.description}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.updateStatus}</label>
<select
value={editStatus[complaint.id] ?? complaint.status}
onChange={(e) => setEditStatus((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
className="input-field"
>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.notes}</label>
<textarea
value={editNotes[complaint.id] ?? ''}
onChange={(e) => setEditNotes((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.resolution}</label>
<textarea
value={editResolution[complaint.id] ?? ''}
onChange={(e) => setEditResolution((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => handleUpdate(complaint.id)}
disabled={savingId === complaint.id}
className="btn-primary text-sm"
>
{savingId === complaint.id ? t.saving : t.save}
</button>
<button onClick={() => setExpandedId(null)} className="btn-secondary text-sm">{t.cancel}</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type ReservationRow = {
id: string
contractNumber: string | null
invoiceNumber: string | null
status: string
paymentStatus: string
startDate: string
endDate: string
totalAmount: number
customer: { firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; licensePlate: string }
}
export default function ContractsPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<ReservationRow[]>([])
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
heading: 'Contracts',
subtitle: 'Generate or reopen rental contracts from any booking.',
search: 'Search customer, vehicle, plate, contract number…',
booking: 'Booking',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
contract: 'Contract',
invoice: 'Invoice',
total: 'Total',
status: 'Status',
action: 'Action',
open: 'Open contract',
generate: 'Generate contract',
empty: 'No bookings found.',
},
fr: {
heading: 'Contrats',
subtitle: 'Générez ou rouvrez un contrat de location depuis nimporte quelle réservation.',
search: 'Rechercher client, véhicule, plaque, numéro de contrat…',
booking: 'Réservation',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
contract: 'Contrat',
invoice: 'Facture',
total: 'Total',
status: 'Statut',
action: 'Action',
open: 'Ouvrir le contrat',
generate: 'Générer le contrat',
empty: 'Aucune réservation trouvée.',
},
ar: {
heading: 'العقود',
subtitle: 'أنشئ أو افتح عقد التأجير من أي حجز.',
search: 'ابحث بالعميل أو السيارة أو اللوحة أو رقم العقد…',
booking: 'الحجز',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
contract: 'العقد',
invoice: 'الفاتورة',
total: 'الإجمالي',
status: 'الحالة',
action: 'الإجراء',
open: 'فتح العقد',
generate: 'إنشاء العقد',
empty: 'لم يتم العثور على حجوزات.',
},
}[language]
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((data) => setRows(data ?? []))
.catch((err) => setError(err.message ?? 'Failed to load contracts'))
}, [])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.filter((row) =>
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
row.customer.email.toLowerCase().includes(q) ||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
(row.contractNumber ?? '').toLowerCase().includes(q) ||
(row.invoiceNumber ?? '').toLowerCase().includes(q),
)
}, [rows, search])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const formatDate = (iso: string) => new Date(iso).toLocaleString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.contract}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm">
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p>{row.vehicle.make} {row.vehicle.model}</p>
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-600">
<p>{formatDate(row.startDate)}</p>
<p>{formatDate(row.endDate)}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.contractNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.invoiceNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm">
<div className="flex flex-col gap-2">
<span className="badge-blue">{row.status}</span>
<span className="badge-gray">{row.paymentStatus}</span>
</div>
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right">
<Link href={`/contracts/${row.id}`} className="text-sm font-semibold text-blue-700 hover:underline">
{row.contractNumber ? copy.open : copy.generate}
</Link>
</td>
</tr>
))}
{filteredRows.length === 0 && (
<tr>
<td colSpan={8} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface CustomerRow {
id: string
firstName: string
lastName: string
email: string
phone: string | null
flagged: boolean
licenseValidationStatus: string
licenseImageUrl: string | null
}
export default function CustomersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Customers',
subtitle: 'A company CRM with license validation and risk flags.',
customer: 'Customer',
contact: 'Contact',
license: 'License',
flags: 'Flags',
imageUploaded: 'Image uploaded',
imageMissing: 'No image',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
empty: 'No customers yet.',
},
fr: {
title: 'Clients',
subtitle: 'CRM de lentreprise avec validation des permis et indicateurs de risque.',
customer: 'Client',
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
imageUploaded: 'Image téléversée',
imageMissing: 'Aucune image',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
empty: 'Aucun client pour le moment.',
},
ar: {
title: 'العملاء',
subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.',
customer: 'العميل',
contact: 'التواصل',
license: 'الرخصة',
flags: 'العلامات',
imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف',
flagged: 'تم تمييزه',
clear: 'لا توجد مخاطر',
empty: 'لا يوجد عملاء حتى الآن.',
},
}[language]
useEffect(() => {
apiFetch<CustomerRow[]>('/customers?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">{row.licenseValidationStatus}</span>
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
</span>
</div>
</td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,838 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, DollarSign, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
import VehiclePricingManager from '@/components/VehiclePricingManager'
import { useDashboardI18n } from '@/components/I18nProvider'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
status: string
color: string
transmission: string
fuelType: string
seats: number
mileage: number | null
licensePlate: string
vin: string | null
photos: string[]
notes: string | null
isPublished: boolean
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function formatLocationInput(values: string[]) {
return Array.isArray(values) ? values.join(', ') : ''
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
function initModelSelect(make: string, model: string) {
const presets = MODELS_BY_MAKE[make]
if (!presets) return model ? 'custom' : ''
return presets.includes(model) ? model : (model ? 'custom' : '')
}
function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
interface MaintenanceLog {
id: string
type: string
description: string | null
cost: number | null
mileage: number | null
performedAt: string
nextDueAt: string | null
nextDueMileage: number | null
}
const ROUTINE_TYPES = [
{ key: 'Oil Change', intervalMonths: 6 },
{ key: 'Technical Inspection', intervalMonths: 12 },
{ key: 'Vehicle Registration', intervalMonths: 12 },
{ key: 'Car Tax', intervalMonths: 12 },
{ key: 'Tire Rotation', intervalMonths: 6 },
{ key: 'Brake Inspection', intervalMonths: 12 },
{ key: 'Air Filter', intervalMonths: 12 },
{ key: 'Cabin Filter', intervalMonths: 12 },
{ key: 'Battery Check', intervalMonths: 12 },
{ key: 'Belt / Chain Service', intervalMonths: 24 },
{ key: 'Coolant Flush', intervalMonths: 24 },
{ key: 'Transmission Service', intervalMonths: 24 },
]
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
if (!log) return 'none'
const now = new Date()
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
if (log.nextDueAt) {
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays < 0) return 'overdue'
if (diffDays <= 30) worstLevel = 'due-soon'
}
if (log.nextDueMileage != null && currentMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
if (kmLeft <= 0) return 'overdue'
if (kmLeft <= 500) worstLevel = 'due-soon'
}
return worstLevel
}
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
return <span className="badge-green text-xs">{vd.statusOk}</span>
}
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
vehicleId: string
typeKey: string
intervalMonths: number
currentMileage: number | null
log: MaintenanceLog | undefined
onLogged: () => void
}) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const [open, setOpen] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const defaultNextDue = (() => {
const d = new Date()
d.setMonth(d.getMonth() + intervalMonths)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
description: '', cost: '', mileage: '', performedAt: today,
nextDueAt: defaultNextDue, nextDueMileage: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.performedAt) { setError(vd.dateRequired); return }
setSaving(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: typeKey,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
}),
})
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
setOpen(false)
onLogged()
} catch (err: any) {
setError(err.message ?? vd.savingLabel)
} finally {
setSaving(false)
}
}
const status = serviceStatus(log, currentMileage)
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
const nextDueText = (() => {
if (!log) return vd.noRecord
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
const dueParts: string[] = []
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
if (currentMileage != null && log.nextDueMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
}
return parts.join(' ')
})()
return (
<div className="rounded-xl border border-slate-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusPill status={status} />
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{vd.logBtn}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{open && (
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
</div>
</form>
)}
</div>
)
}
type Tab = 'details' | 'maintenance' | 'calendar' | 'pricing'
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const searchParams = useSearchParams()
const { dict, language } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<Tab>('details')
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [form, setForm] = useState<{
make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; vin: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
const fetchMaintenance = () => {
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
.then((logs) => setMaintenanceLogs(logs ?? []))
.catch(() => {})
}
useEffect(() => { fetchMaintenance() }, [params.id])
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
const [uploadingPhotos, setUploadingPhotos] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
.then(setVehicle)
.catch((err) => setError(err.message))
}, [params.id])
useEffect(() => {
if (searchParams.get('tab') === 'pricing') {
setActiveTab('pricing')
}
}, [searchParams])
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make, model: vehicle.model, year: vehicle.year,
category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate,
vin: vehicle.vin ?? '',
color: vehicle.color ?? '', seats: vehicle.seats,
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
status: vehicle.status, notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
pickupLocations: formatLocationInput(vehicle.pickupLocations),
allowDifferentDropoff: vehicle.allowDifferentDropoff,
dropoffLocations: formatLocationInput(vehicle.dropoffLocations),
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
setColorSelect(initColorSelect(vehicle.color ?? ''))
setSaveError(null)
setEditing(true)
}
const cancelEdit = () => {
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setEditing(false)
setForm(null)
setSaveError(null)
}
const handleSave = async () => {
if (!form || !vehicle) return
if (!form.make) { setSaveError(vd.makeRequired); return }
if (!form.model) { setSaveError(vd.modelRequired); return }
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setSaveError(fl.dropoffLocationsRequired)
return
}
setSaving(true)
setSaveError(null)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH',
body: JSON.stringify({
make: form.make, model: form.model, year: Number(form.year),
category: form.category, dailyRate: Math.round(rate * 100),
licensePlate: form.licensePlate, vin: form.vin.trim() || undefined, color: form.color,
seats: Number(form.seats), transmission: form.transmission,
fuelType: form.fuelType, status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
let finalVehicle = updated
if (newPhotoFiles.length > 0) {
setUploadingPhotos(true)
const fd = new FormData()
newPhotoFiles.forEach((f) => fd.append('photos', f))
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setUploadingPhotos(false)
}
setVehicle(finalVehicle)
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? vd.failedSave)
setUploadingPhotos(false)
} finally {
setSaving(false)
}
}
const handleDeletePhoto = async (idx: number) => {
if (!vehicle) return
setDeletingPhotoIdx(idx)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
setVehicle(updated)
} catch (err: any) {
alert(err.message)
} finally {
setDeletingPhotoIdx(null)
}
}
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = vehicle?.photos.length ?? 0
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removeNewPhoto = (i: number) => {
URL.revokeObjectURL(newPhotoPreviews[i])
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">{vd.loadingVehicle}</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
const categoryLabel = fl.categoryLabels[vehicle.category as keyof typeof fl.categoryLabels] ?? vehicle.category
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
const pricingTabLabel = language === 'fr' ? 'Tarifs' : language === 'ar' ? 'التسعير' : 'Pricing'
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
{' · '}
{statusLabel}
</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> {vd.editBtn}
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> {vd.cancelBtn}
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
</button>
</div>
)}
</div>
{saveError && (
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
)}
{/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
{(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
{tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
{tab === 'maintenance' && (
<>
<Wrench className="w-4 h-4" /> {vd.tabMaintenance}
{maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-orange-100 text-orange-700 text-[10px] font-bold">!</span>}
</>
)}
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
{tab === 'pricing' && <><DollarSign className="w-4 h-4" /> {pricingTabLabel}</>}
</button>
))}
</div>
{activeTab === 'details' && (
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
{editing && (
<button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button
onClick={() => removeNewPhoto(i)}
className="absolute top-2 right-2 p-1.5 bg-blue-950/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3.5 h-3.5" />
</button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
</div>
))}
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
)}
</div>
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
)}
</div>
{/* Details / Edit form */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.vehicleDetailsHeading}</h3>
{!editing || !form ? (
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<div><dt className="text-slate-500">{vd.vinLabel}</dt><dd className="text-slate-900">{vehicle.vin || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.pickupLocations}</dt><dd className="text-slate-900">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.join(', ') : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.allowDifferentDropoff}</dt><dd className="text-slate-900">{vehicle.allowDifferentDropoff ? fl.differentDropoffAvailable : fl.sameDropoffOnly}</dd></div>
{vehicle.allowDifferentDropoff && (
<div><dt className="text-slate-500">{fl.dropoffLocations}</dt><dd className="text-slate-900">{vehicle.dropoffLocations.length > 0 ? vehicle.dropoffLocations.join(', ') : '—'}</dd></div>
)}
<div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{vd.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
{!presetModels ? (
<input className="input-field" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{vd.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.yearLabel}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{fl.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* Daily Rate, License Plate & VIN */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.licensePlateLabel}</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.vinLabel}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
{/* Status */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={s} value={s}>{fl.statusLabels[s]}</option>
))}
</select>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{vd.selectColor}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{vd.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.seatsLabel}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{fl.manual}</option>
<option value="AUTOMATIC">{fl.automatic}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</label>
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{fl.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'maintenance' && (
<div className="space-y-3">
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
const latest = maintenanceLogs
.filter((l) => l.type === key)
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
return (
<MaintenanceRow
key={key}
vehicleId={params.id}
typeKey={key}
intervalMonths={intervalMonths}
currentMileage={vehicle.mileage}
log={latest}
onLogged={fetchMaintenance}
/>
)
})}
</div>
)}
{activeTab === 'calendar' && (
<VehicleCalendar vehicleId={params.id} />
)}
{activeTab === 'pricing' && (
<VehiclePricingManager vehicleId={params.id} />
)}
</div>
)
}
@@ -0,0 +1,806 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
status: 'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE'
dailyRate: number
isPublished: boolean
primaryPhoto: string | null
licensePlate: string
vin?: string | null
pickupLocations?: string[]
allowDifferentDropoff?: boolean
dropoffLocations?: string[]
}
interface AddVehicleModalProps {
open: boolean
onClose: () => void
onSaved: () => void
}
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
const ALL_STATUSES: Vehicle['status'][] = [
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
]
interface MaintenanceModalProps {
vehicleId: string
vehicleName: string
open: boolean
onClose: () => void
onSaved: () => void
}
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const today = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const reset = () => {
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.type) { setError(f.maintenanceTypeRequired); return }
setLoading(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: form.type,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
}),
})
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToSave)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
{error && <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
</div>
</form>
</div>
</div>
)
}
function StatusDropdown({ vehicleId, status, onStatusChange, disabled, disabledReason, onError }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => void
disabled?: boolean
disabledReason?: string
onError: (message: string) => void
}) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [saving, setSaving] = useState(false)
const select = async (next: Vehicle['status']) => {
if (next === status) return
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
onError(err.message ?? 'Failed to update vehicle status')
} finally {
setSaving(false)
}
}
return (
<select
value={status}
disabled={disabled || saving}
title={disabled ? disabledReason : undefined}
onChange={(e) => void select(e.target.value as Vehicle['status'])}
className={[
'rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm',
disabled || saving ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-300',
].join(' ')}
>
{ALL_STATUSES.map((value) => (
<option key={value} value={value}>
{f.statusLabels[value]}
</option>
))}
</select>
)
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [photoFiles, setPhotoFiles] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
e.target.value = ''
}
const removePhoto = (index: number) => {
URL.revokeObjectURL(photoPreviews[index])
const files = photoFiles.filter((_, i) => i !== index)
const previews = photoPreviews.filter((_, i) => i !== index)
setPhotoFiles(files)
setPhotoPreviews(previews)
}
const reset = () => {
setForm({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
setColorSelect('')
setMakeSelect('')
setModelSelect('')
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
setPhotoFiles([])
setPhotoPreviews([])
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setError(f.dropoffLocationsRequired)
return
}
setLoading(true)
setError(null)
try {
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
method: 'POST',
body: JSON.stringify({
...form,
year: Number(form.year),
seats: Number(form.seats),
vin: form.vin.trim() || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((file) => fd.append('photos', file))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToAdd)
} finally {
setLoading(false)
}
}
if (!open) return null
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</h2>
{error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Make & Model */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{f.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeMake} required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
{!presetModels ? (
<input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{f.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
</div>
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* License Plate & VIN */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
</div>
{/* Color, Seats, Transmission */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{f.select}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{f.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.seats}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.transmission}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{f.manual}</option>
<option value="AUTOMATIC">{f.automatic}</option>
</select>
</div>
</div>
{/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
<option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{f.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
)}
</div>
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => removePhoto(i)}
className="absolute top-1 right-1 p-0.5 bg-blue-950/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{photoFiles.length < 10 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? f.adding : f.addVehicle}
</button>
</div>
</form>
</div>
</div>
)
}
export default function FleetPage() {
const { dict, language } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const f = dict.fleet
const managerRequiredMessage =
language === 'fr'
? 'Cette action nécessite le rôle Gestionnaire ou supérieur.'
: language === 'ar'
? 'يتطلب هذا الإجراء دور المدير أو أعلى.'
: 'This action requires the Manager role or higher.'
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [employeeRole, setEmployeeRole] = useState<EmployeeRole | null>(null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [publishedFilter, setPublishedFilter] = useState('')
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
const fetchVehicles = () => {
setLoading(true)
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
.then((result) => setVehicles(result ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => { fetchVehicles() }, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { role?: EmployeeRole }
if (profile.role) setEmployeeRole(profile.role)
} catch {}
}
let cancelled = false
apiFetch<{ employee: { role: EmployeeRole } }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
setEmployeeRole(employee.role)
})
.catch(() => {})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (searchParams.get('modal') === 'add-car') {
setShowAddModal(true)
}
}, [searchParams])
const canManageFleet = employeeRole === 'OWNER' || employeeRole === 'MANAGER'
function closeAddModal() {
setShowAddModal(false)
if (searchParams.get('modal') === 'add-car') {
const params = new URLSearchParams(searchParams.toString())
params.delete('modal')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
}
}
const togglePublished = async (id: string, current: boolean) => {
setActionError(null)
try {
await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) })
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v))
} catch (err: any) {
setActionError(err.statusCode === 403 ? managerRequiredMessage : (err.message ?? 'Failed to update publication status'))
}
}
const PUBLISHED = new Set<Vehicle['status']>(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = PUBLISHED.has(status)
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
const vehicle = vehicles.find((v) => v.id === id)
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
}
}
const filtered = vehicles.filter((v) => {
if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false
if (statusFilter && v.status !== statusFilter) return false
if (categoryFilter && v.category !== categoryFilter) return false
if (publishedFilter === 'published' && !v.isPublished) return false
if (publishedFilter === 'unpublished' && v.isPublished) return false
return true
})
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
</div>
<button onClick={() => setShowAddModal(true)} className="btn-primary" disabled={!canManageFleet} title={!canManageFleet ? managerRequiredMessage : undefined}>
<Plus className="w-4 h-4" /> {f.addVehicle}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
{/* Filters */}
<div className="card p-4 flex flex-wrap gap-3">
<div className="flex items-center gap-2 flex-1 min-w-48">
<Search className="w-4 h-4 text-slate-400" />
<input
className="flex-1 text-sm outline-none placeholder:text-slate-400"
placeholder={f.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{f.allStatuses}</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{f.statusLabels[s]}</option>
))}
</select>
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="">{f.allCategories}</option>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
<option value="">{f.allVisibility}</option>
<option value="published">{f.published}</option>
<option value="unpublished">{f.unpublished}</option>
</select>
</div>
{/* Table */}
<div className="card overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 font-medium">{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
<th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((vehicle) => (
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
</p>
</div>
</td>
<td className="px-6 py-4">
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td>
<td className="px-6 py-4">
<StatusDropdown
vehicleId={vehicle.id}
status={vehicle.status}
onStatusChange={changeStatus}
disabled={!canManageFleet}
disabledReason={managerRequiredMessage}
onError={setActionError}
/>
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
disabled={!canManageFleet}
title={!canManageFleet ? managerRequiredMessage : undefined}
className={[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
!canManageFleet ? 'cursor-not-allowed opacity-60' : '',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
<span
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
].join(' ')}
/>
</button>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/fleet/${vehicle.id}?tab=pricing`} title="Manage pricing" aria-label="Manage pricing" className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<DollarSign className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
{vehicles.length === 0 ? f.noVehicles : f.noMatch}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<AddVehicleModal open={showAddModal} onClose={closeAddModal} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
vehicleName={maintenanceModal.vehicleName}
open={true}
onClose={() => setMaintenanceModal(null)}
onSaved={() => setMaintenanceModal(null)}
/>
)}
</div>
)
}
+23
View File
@@ -0,0 +1,23 @@
import DashboardAccessGuard from '@/components/layout/DashboardAccessGuard'
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="rdg-app-shell fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
<div className="rdg-workspace ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-[var(--rdg-sidebar-size)] print:ms-0 print:min-h-0 print:block">
<div className="print:hidden">
<TopBar />
</div>
<main className="rdg-app-main flex-1 overflow-y-auto print:overflow-visible print:p-0">
<div className="rdg-app-content">
<DashboardAccessGuard>{children}</DashboardAccessGuard>
</div>
</main>
</div>
</div>
)
}
@@ -0,0 +1,469 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type NotificationItem = {
id: string
type: string
title: string
body: string
channel: string
status: string
sentAt: string | null
readAt: string | null
createdAt: string
providerMessageId: string | null
locale: string
}
type PreferenceItem = {
notificationType: string
channel: string
enabled: boolean
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
}
const STATUS_BADGE: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
}
export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [history, setHistory] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const copy = {
en: {
title: 'Notifications',
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
inbox: 'Inbox',
history: 'History',
preferences: 'Preferences',
markAllRead: 'Mark all as read',
loading: 'Loading notifications…',
empty: 'No notifications yet.',
read: 'Read',
markRead: 'Mark as read',
event: 'Event',
channel: 'Channel',
status: 'Status',
sentAt: 'Sent at',
date: 'Date',
allChannels: 'All channels',
allStatuses: 'All statuses',
savePreferences: 'Save preferences',
saving: 'Saving…',
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
} as Record<string, string>,
},
fr: {
title: 'Notifications',
subtitle: 'Suivez les alertes, auditez l\'historique de diffusion et gérez les préférences de votre équipe.',
inbox: 'Boîte de réception',
history: 'Historique',
preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications…',
empty: 'Aucune notification pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
event: 'Événement',
channel: 'Canal',
status: 'Statut',
sentAt: 'Envoyé le',
date: 'Date',
allChannels: 'Tous les canaux',
allStatuses: 'Tous les statuts',
savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement…',
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de l\'enregistrement des préférences',
noHistory: 'Aucun historique de notification trouvé.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
} as Record<string, string>,
},
ar: {
title: 'الإشعارات',
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
inbox: 'صندوق الوارد',
history: 'السجل',
preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات حالياً.',
read: 'مقروء',
markRead: 'تحديد كمقروء',
event: 'الحدث',
channel: 'القناة',
status: 'الحالة',
sentAt: 'وقت الإرسال',
date: 'التاريخ',
allChannels: 'جميع القنوات',
allStatuses: 'جميع الحالات',
savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
} as Record<string, string>,
},
}[language]
async function load() {
setLoading(true)
setError(null)
try {
const [notificationData, preferenceData] = await Promise.all([
apiFetch<NotificationItem[]>('/notifications/company'),
apiFetch<PreferenceItem[]>('/notifications/company/preferences'),
])
setNotifications(notificationData)
const mapped = Object.fromEntries(
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
)
setPreferences(mapped)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setLoading(false)
}
}
async function loadHistory() {
setHistoryLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
const qs = params.toString()
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
setHistory(data)
setHistoryLoaded(true)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setHistoryLoading(false)
}
}
useEffect(() => {
load()
}, [])
useEffect(() => {
if (activeTab === 'history') {
loadHistory()
}
}, [activeTab, filterChannel, filterStatus])
async function markRead(id: string) {
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
)
window.dispatchEvent(new Event('notifications:updated'))
}
async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
window.dispatchEvent(new Event('notifications:updated'))
}
async function savePreferences() {
setSaving(true)
setError(null)
try {
const body = Object.entries(preferences).map(([key, enabled]) => {
const [notificationType, channel] = key.split(':')
return { notificationType, channel, enabled }
})
await apiFetch('/notifications/company/preferences', {
method: 'PATCH',
body: JSON.stringify(body),
})
} catch (err: any) {
setError(err.message ?? copy.failedSave)
} finally {
setSaving(false)
}
}
function preferenceValue(notificationType: string, channel: string) {
return preferences[`${notificationType}:${channel}`] ?? true
}
function setPreference(notificationType: string, channel: string, enabled: boolean) {
setPreferences((current) => ({
...current,
[`${notificationType}:${channel}`]: enabled,
}))
}
function labelEvent(type: string) {
return copy.events[type] ?? type.replaceAll('_', ' ')
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
>
{copy.inbox}
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
>
{copy.history}
</button>
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
>
{copy.preferences}
</button>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
{/* ── Inbox ── */}
{activeTab === 'inbox' ? (
<div className="space-y-4">
<div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary">
{copy.markAllRead}
</button>
</div>
{loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
{!loading
? notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labelEvent(notification.type)}
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
{copy.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))
: null}
</div>
) : null}
{/* ── History ── */}
{activeTab === 'history' ? (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allChannels}</option>
{COMPANY_CHANNELS.map((ch) => (
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allStatuses}</option>
{Object.keys(copy.statuses).map((s) => (
<option key={s} value={s}>{copy.statuses[s]}</option>
))}
</select>
</div>
{historyLoading ? (
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
) : historyLoaded && history.length === 0 ? (
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
{history.map((item) => (
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-blue-900/20">
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
{labelEvent(item.type)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.channels[item.channel] ?? item.channel}
</span>
</td>
<td className="max-w-[260px] px-4 py-3">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statuses[item.status] ?? item.status}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : null}
{/* ── Preferences ── */}
{activeTab === 'preferences' ? (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
{COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
{copy.channels[channel] ?? channel.replace('_', ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={preferenceValue(eventName, channel)}
onChange={(event) => setPreference(eventName, channel, event.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? copy.saving : copy.savePreferences}
</button>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,797 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { formatCurrency, getCurrencyLabel } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
type Category = typeof CATEGORIES[number]
interface Offer {
id: string
title: string
description?: string | null
termsAndConds?: string | null
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_DAY' | 'SPECIAL_RATE'
discountValue: number
specialRate?: number | null
appliesToAll: boolean
categories: Category[]
minRentalDays?: number | null
maxRentalDays?: number | null
promoCode?: string | null
maxRedemptions?: number | null
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
redemptionCount?: number
vehicles?: Array<{ vehicleId: string }>
}
interface FleetVehicle {
id: string
make: string
model: string
year: number
category: string
licensePlate: string
}
interface FormState {
title: string
description: string
termsAndConds: string
type: Offer['type']
discountValue: string
specialRate: string
appliesToAll: boolean
categories: Category[]
vehicleIds: string[]
minRentalDays: string
maxRentalDays: string
promoCode: string
maxRedemptions: string
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
}
function emptyForm(): FormState {
const today = dayjs().format('YYYY-MM-DD')
const nextMonth = dayjs().add(30, 'day').format('YYYY-MM-DD')
return {
title: '',
description: '',
termsAndConds: '',
type: 'PERCENTAGE',
discountValue: '',
specialRate: '',
appliesToAll: true,
categories: [],
vehicleIds: [],
minRentalDays: '',
maxRentalDays: '',
promoCode: '',
maxRedemptions: '',
validFrom: today,
validUntil: nextMonth,
isActive: true,
isPublic: true,
isFeatured: false,
}
}
function offerToForm(o: Offer): FormState {
return {
title: o.title,
description: o.description ?? '',
termsAndConds: o.termsAndConds ?? '',
type: o.type,
discountValue: String(o.discountValue),
specialRate: o.specialRate != null ? String(o.specialRate) : '',
appliesToAll: o.appliesToAll,
categories: o.categories ?? [],
vehicleIds: o.vehicles?.map((v) => v.vehicleId) ?? [],
minRentalDays: o.minRentalDays != null ? String(o.minRentalDays) : '',
maxRentalDays: o.maxRentalDays != null ? String(o.maxRentalDays) : '',
promoCode: o.promoCode ?? '',
maxRedemptions: o.maxRedemptions != null ? String(o.maxRedemptions) : '',
validFrom: dayjs(o.validFrom).format('YYYY-MM-DD'),
validUntil: dayjs(o.validUntil).format('YYYY-MM-DD'),
isActive: o.isActive,
isPublic: o.isPublic,
isFeatured: o.isFeatured,
}
}
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
createOffer: 'Create Offer',
editOffer: 'Edit Offer',
deleteOffer: 'Delete Offer',
confirmDelete: 'Are you sure you want to delete this offer? This action cannot be undone.',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving…',
delete: 'Delete',
deleting: 'Deleting…',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers yet. Create your first offer to attract customers.',
labelTitle: 'Title *',
labelDescription: 'Description',
labelTerms: 'Terms & Conditions',
labelType: 'Discount Type *',
labelDiscount: 'Discount Value *',
labelSpecialRate: 'Special Rate (MAD/day) *',
labelAppliesToAll: 'Applies to all vehicles',
labelCategories: 'Vehicle Categories',
labelVehicles: 'Specific Vehicles',
labelVehicleSearch: 'Search vehicles…',
labelMinDays: 'Min Rental Days',
labelMaxDays: 'Max Rental Days',
labelPromoCode: 'Promo Code',
labelMaxRedemptions: 'Max Redemptions',
labelValidFrom: 'Valid From *',
labelValidUntil: 'Valid Until *',
labelIsActive: 'Active',
labelIsPublic: 'Show on marketplace',
labelIsFeatured: 'Featured',
typePERCENTAGE: 'Percentage (%)',
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
typeFREE_DAY: 'Free Day',
typeSPECIAL_RATE: 'Special Daily Rate',
placeholderTitle: 'e.g. Summer Promotion',
placeholderDescription: 'Optional description…',
placeholderTerms: 'Terms and conditions…',
placeholderPromoCode: 'e.g. SUMMER20',
errorTitle: 'Title is required.',
errorDiscount: 'Please enter a valid discount value.',
errorDates: 'Valid From and Valid Until are required.',
errorDateOrder: 'Valid Until must be after Valid From.',
failedSave: 'Failed to save offer.',
failedDelete: 'Failed to delete offer.',
redemptions: 'redemptions',
freeDay: 'Free day',
loadingVehicles: 'Loading fleet…',
noVehiclesFound: 'No vehicles found.',
selected: (n: number) => `${n} selected`,
categoryLabels: {
ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size',
SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck',
},
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
createOffer: 'Créer une offre',
editOffer: "Modifier l'offre",
deleteOffer: "Supprimer l'offre",
confirmDelete: 'Êtes-vous sûr de vouloir supprimer cette offre ? Cette action est irréversible.',
cancel: 'Annuler',
save: 'Enregistrer',
saving: 'Enregistrement…',
delete: 'Supprimer',
deleting: 'Suppression…',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
labelTitle: 'Titre *',
labelDescription: 'Description',
labelTerms: 'Conditions générales',
labelType: 'Type de remise *',
labelDiscount: 'Valeur de la remise *',
labelSpecialRate: 'Tarif spécial (MAD/jour) *',
labelAppliesToAll: 'Applicable à tous les véhicules',
labelCategories: 'Catégories de véhicules',
labelVehicles: 'Véhicules spécifiques',
labelVehicleSearch: 'Rechercher des véhicules…',
labelMinDays: 'Jours de location minimum',
labelMaxDays: 'Jours de location maximum',
labelPromoCode: 'Code promo',
labelMaxRedemptions: 'Nombre max de rédemptions',
labelValidFrom: 'Valable du *',
labelValidUntil: "Valable jusqu'au *",
labelIsActive: 'Actif',
labelIsPublic: 'Afficher sur la marketplace',
labelIsFeatured: 'Mise en avant',
typePERCENTAGE: 'Pourcentage (%)',
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
typeFREE_DAY: 'Jour gratuit',
typeSPECIAL_RATE: 'Tarif journalier spécial',
placeholderTitle: 'ex. Promotion estivale',
placeholderDescription: 'Description optionnelle…',
placeholderTerms: 'Conditions générales…',
placeholderPromoCode: 'ex. ETE20',
errorTitle: 'Le titre est requis.',
errorDiscount: 'Veuillez saisir une valeur de remise valide.',
errorDates: 'Les dates de début et de fin sont requises.',
errorDateOrder: 'La date de fin doit être après la date de début.',
failedSave: "Échec de l'enregistrement de l'offre.",
failedDelete: "Échec de la suppression de l'offre.",
redemptions: 'rédemptions',
freeDay: 'Jour gratuit',
loadingVehicles: 'Chargement de la flotte…',
noVehiclesFound: 'Aucun véhicule trouvé.',
selected: (n: number) => `${n} sélectionné${n > 1 ? 's' : ''}`,
categoryLabels: {
ECONOMY: 'Économique', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline',
SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camionnette',
},
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
createOffer: 'إنشاء عرض',
editOffer: 'تعديل العرض',
deleteOffer: 'حذف العرض',
confirmDelete: 'هل أنت متأكد من حذف هذا العرض؟ لا يمكن التراجع عن هذا الإجراء.',
cancel: 'إلغاء',
save: 'حفظ',
saving: 'جارٍ الحفظ…',
delete: 'حذف',
deleting: 'جارٍ الحذف…',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
labelTitle: 'العنوان *',
labelDescription: 'الوصف',
labelTerms: 'الشروط والأحكام',
labelType: 'نوع الخصم *',
labelDiscount: 'قيمة الخصم *',
labelSpecialRate: 'السعر الخاص (درهم/يوم) *',
labelAppliesToAll: 'ينطبق على جميع المركبات',
labelCategories: 'فئات المركبات',
labelVehicles: 'مركبات محددة',
labelVehicleSearch: 'ابحث عن مركبة…',
labelMinDays: 'الحد الأدنى لأيام الإيجار',
labelMaxDays: 'الحد الأقصى لأيام الإيجار',
labelPromoCode: 'رمز الترويج',
labelMaxRedemptions: 'الحد الأقصى للاسترداد',
labelValidFrom: 'صالح من *',
labelValidUntil: 'صالح حتى *',
labelIsActive: 'نشط',
labelIsPublic: 'عرض في السوق',
labelIsFeatured: 'مميز',
typePERCENTAGE: 'نسبة مئوية (%)',
typeFIXED_AMOUNT: 'مبلغ ثابت (درهم)',
typeFREE_DAY: 'يوم مجاني',
typeSPECIAL_RATE: 'سعر يومي خاص',
placeholderTitle: 'مثال: عرض الصيف',
placeholderDescription: 'وصف اختياري…',
placeholderTerms: 'الشروط والأحكام…',
placeholderPromoCode: 'مثال: SUMMER20',
errorTitle: 'العنوان مطلوب.',
errorDiscount: 'يرجى إدخال قيمة خصم صحيحة.',
errorDates: 'تاريخ البداية والنهاية مطلوبان.',
errorDateOrder: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ البداية.',
failedSave: 'فشل حفظ العرض.',
failedDelete: 'فشل حذف العرض.',
redemptions: 'استرداد',
freeDay: 'يوم مجاني',
loadingVehicles: 'جارٍ تحميل الأسطول…',
noVehiclesFound: 'لم يتم العثور على مركبات.',
selected: (n: number) => `${n} محدد`,
categoryLabels: {
ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة',
SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة',
},
},
}
function discountLabel(offer: Offer, t: typeof copy['en'], language: 'en' | 'fr' | 'ar'): string {
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
if (offer.type === 'FREE_DAY') return t.freeDay
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} ${getCurrencyLabel(language)}${language === 'fr' ? '/jour' : language === 'ar' ? '/يوم' : '/day'}`
return String(offer.discountValue)
}
export default function OffersPage() {
const { language } = useDashboardI18n()
const t = copy[language]
const [offers, setOffers] = useState<Offer[]>([])
const [error, setError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Offer | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [formError, setFormError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Offer | null>(null)
const [deleting, setDeleting] = useState(false)
const [fleet, setFleet] = useState<FleetVehicle[]>([])
const [loadingFleet, setLoadingFleet] = useState(false)
const [vehicleSearch, setVehicleSearch] = useState('')
function load() {
apiFetch<Offer[]>('/offers')
.then((data) => {
setOffers(Array.isArray(data) ? data : [])
setError(null)
})
.catch((err) => setError(err.message))
}
useEffect(() => { load() }, [])
async function loadFleet() {
setLoadingFleet(true)
try {
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
setFleet(Array.isArray(data) ? data : [])
} catch {
// non-fatal; vehicle picker just stays empty
} finally {
setLoadingFleet(false)
}
}
function openCreate() {
setEditing(null)
setForm(emptyForm())
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
loadFleet()
}
async function openEdit(offer: Offer) {
setEditing(offer)
setForm(offerToForm(offer))
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
// Load fleet and the offer's current vehicle IDs in parallel
loadFleet()
try {
const detail = await apiFetch<Offer>(`/offers/${offer.id}`)
setForm((prev) => ({
...prev,
vehicleIds: Array.isArray(detail?.vehicles) ? detail.vehicles.map((v) => v.vehicleId) : [],
}))
} catch {
// leave vehicleIds as empty if fetch fails
}
}
function closeModal() {
setModalOpen(false)
setEditing(null)
setFormError(null)
}
function toggleCategory(cat: Category) {
setForm((f) => ({
...f,
categories: f.categories.includes(cat)
? f.categories.filter((c) => c !== cat)
: [...f.categories, cat],
}))
}
function toggleVehicle(id: string) {
setForm((f) => ({
...f,
vehicleIds: f.vehicleIds.includes(id)
? f.vehicleIds.filter((v) => v !== id)
: [...f.vehicleIds, id],
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError(null)
if (!form.title.trim()) return setFormError(t.errorTitle)
if (!form.validFrom || !form.validUntil) return setFormError(t.errorDates)
if (form.validUntil <= form.validFrom) return setFormError(t.errorDateOrder)
if (form.type !== 'FREE_DAY') {
const dv = Number(form.discountValue)
if (isNaN(dv) || dv < 0) return setFormError(t.errorDiscount)
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
title: form.title.trim(),
description: form.description.trim() || undefined,
termsAndConds: form.termsAndConds.trim() || undefined,
type: form.type,
discountValue: form.type === 'FREE_DAY' ? 0 : Number(form.discountValue),
specialRate: form.specialRate ? Number(form.specialRate) : undefined,
appliesToAll: form.appliesToAll,
categories: form.appliesToAll ? [] : form.categories,
minRentalDays: form.minRentalDays ? Number(form.minRentalDays) : undefined,
maxRentalDays: form.maxRentalDays ? Number(form.maxRentalDays) : undefined,
promoCode: form.promoCode.trim() || undefined,
maxRedemptions: form.maxRedemptions ? Number(form.maxRedemptions) : undefined,
validFrom: new Date(form.validFrom).toISOString(),
validUntil: new Date(form.validUntil).toISOString(),
isActive: form.isActive,
isPublic: form.isPublic,
isFeatured: form.isFeatured,
vehicleIds: form.appliesToAll ? [] : form.vehicleIds,
}
if (editing) {
await apiFetch(`/offers/${editing.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
} else {
await apiFetch('/offers', { method: 'POST', body: JSON.stringify(payload) })
}
closeModal()
load()
} catch (err: any) {
setFormError(err.message ?? t.failedSave)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await apiFetch(`/offers/${deleteTarget.id}`, { method: 'DELETE' })
setDeleteTarget(null)
load()
} catch (err: any) {
setFormError(err.message ?? t.failedDelete)
setDeleteTarget(null)
} finally {
setDeleting(false)
}
}
const isRtl = language === 'ar'
const filteredFleet = fleet.filter((v) => {
const q = vehicleSearch.toLowerCase()
return (
!q ||
v.make.toLowerCase().includes(q) ||
v.model.toLowerCase().includes(q) ||
v.licensePlate.toLowerCase().includes(q)
)
})
return (
<div className="space-y-6">
{/* Header */}
<div className="rdg-page-heading flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-slate-900">{t.title}</h2>
<p className="text-sm text-slate-500 mt-1">{t.subtitle}</p>
</div>
<button type="button" onClick={openCreate} className="btn-primary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{t.createOffer}
</button>
</div>
{error && <div className="card p-4 text-sm text-red-600">{error}</div>}
{/* Offer cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
<div key={offer.id} className="card p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 truncate">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-0.5">
{t[`type${offer.type}` as keyof typeof t] as string} · {t.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}
</p>
</div>
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
{offer.isActive ? t.active : t.inactive}
</span>
</div>
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t, language)}</p>
{offer.description && (
<p className="text-sm text-slate-500 line-clamp-2">{offer.description}</p>
)}
<div className="flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">{t.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
{typeof offer.redemptionCount === 'number' && (
<span className="badge-gray">{offer.redemptionCount} {t.redemptions}</span>
)}
</div>
<div className={`flex gap-2 mt-auto pt-2 border-t border-slate-100 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button type="button" onClick={() => openEdit(offer)} className="btn-secondary text-xs px-3 py-1.5">
{t.editOffer}
</button>
<button type="button" onClick={() => setDeleteTarget(offer)} className="btn-danger text-xs px-3 py-1.5">
{t.deleteOffer}
</button>
</div>
</div>
))}
{offers.length === 0 && !error && (
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
)}
</div>
{/* Create / Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-blue-950/50 p-4">
<div className="relative w-full max-w-2xl my-8 card p-6" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-lg font-semibold text-slate-900 mb-5">
{editing ? t.editOffer : t.createOffer}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTitle}</label>
<input
className="input-field"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t.placeholderTitle}
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDescription}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t.placeholderDescription}
/>
</div>
{/* Type + Discount/Rate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelType}</label>
<select
className="input-field"
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as Offer['type'] }))}
>
<option value="PERCENTAGE">{t.typePERCENTAGE}</option>
<option value="FIXED_AMOUNT">{t.typeFIXED_AMOUNT}</option>
<option value="FREE_DAY">{t.typeFREE_DAY}</option>
<option value="SPECIAL_RATE">{t.typeSPECIAL_RATE}</option>
</select>
</div>
<div>
{form.type === 'SPECIAL_RATE' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelSpecialRate}</label>
<input type="number" min={0} className="input-field" value={form.specialRate} onChange={(e) => setForm((f) => ({ ...f, specialRate: e.target.value }))} />
</>
) : form.type !== 'FREE_DAY' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDiscount}</label>
<input type="number" min={0} className="input-field" value={form.discountValue} onChange={(e) => setForm((f) => ({ ...f, discountValue: e.target.value }))} />
</>
) : null}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidFrom}</label>
<input type="date" className="input-field" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidUntil}</label>
<input type="date" className="input-field" value={form.validUntil} onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
</div>
</div>
{/* Promo code + Max Redemptions */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelPromoCode}</label>
<input className="input-field" value={form.promoCode} onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value }))} placeholder={t.placeholderPromoCode} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxRedemptions}</label>
<input type="number" min={1} className="input-field" value={form.maxRedemptions} onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} />
</div>
</div>
{/* Min / Max Rental Days */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMinDays}</label>
<input type="number" min={1} className="input-field" value={form.minRentalDays} onChange={(e) => setForm((f) => ({ ...f, minRentalDays: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxDays}</label>
<input type="number" min={1} className="input-field" value={form.maxRentalDays} onChange={(e) => setForm((f) => ({ ...f, maxRentalDays: e.target.value }))} />
</div>
</div>
{/* Applies-to-all toggle */}
<div>
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form.appliesToAll}
onChange={(e) => setForm((f) => ({ ...f, appliesToAll: e.target.checked }))}
/>
{t.labelAppliesToAll}
</label>
</div>
{/* Scoped selection — only shown when not appliesToAll */}
{!form.appliesToAll && (
<div className="space-y-4 rounded-xl border border-slate-200 p-4">
{/* Category chips */}
<div>
<p className="text-xs font-medium text-slate-600 mb-2">{t.labelCategories}</p>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => toggleCategory(cat)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
form.categories.includes(cat)
? 'bg-blue-600 text-white border-blue-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{t.categoryLabels[cat]}
</button>
))}
</div>
</div>
{/* Vehicle picker */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-600">{t.labelVehicles}</p>
{form.vehicleIds.length > 0 && (
<span className="badge-blue">{t.selected(form.vehicleIds.length)}</span>
)}
</div>
{/* Search input */}
<div className="relative mb-2">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 111 11a6 6 0 0116 0z" />
</svg>
<input
className="input-field pl-8 text-xs py-1.5"
value={vehicleSearch}
onChange={(e) => setVehicleSearch(e.target.value)}
placeholder={t.labelVehicleSearch}
/>
</div>
{/* Vehicle list */}
<div className="max-h-52 overflow-y-auto rounded-lg border border-slate-200 divide-y divide-slate-100">
{loadingFleet ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.loadingVehicles}</p>
) : filteredFleet.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.noVehiclesFound}</p>
) : (
filteredFleet.map((v) => {
const checked = form.vehicleIds.includes(v.id)
return (
<label
key={v.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
checked ? 'bg-blue-50' : 'hover:bg-slate-50'
}`}
>
<input
type="checkbox"
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600"
checked={checked}
onChange={() => toggleVehicle(v.id)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 truncate">
{v.year} {v.make} {v.model}
</p>
<p className="text-xs text-slate-400">{v.licensePlate} · {t.categoryLabels[v.category as Category] ?? v.category}</p>
</div>
</label>
)
})
)}
</div>
</div>
</div>
)}
{/* Terms */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTerms}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.termsAndConds}
onChange={(e) => setForm((f) => ({ ...f, termsAndConds: e.target.value }))}
placeholder={t.placeholderTerms}
/>
</div>
{/* Toggles */}
<div className="flex flex-wrap gap-x-6 gap-y-2">
{([
{ key: 'isActive', label: t.labelIsActive },
{ key: 'isPublic', label: t.labelIsPublic },
{ key: 'isFeatured', label: t.labelIsFeatured },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form[key] as boolean}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.checked }))}
/>
{label}
</label>
))}
</div>
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className={`flex gap-3 pt-2 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={closeModal} className="btn-secondary">{t.cancel}</button>
<button type="submit" disabled={saving} className="btn-primary">
{saving ? t.saving : t.save}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/50 p-4">
<div className="card p-6 w-full max-w-sm" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-base font-semibold text-slate-900 mb-2">{t.deleteOffer}</h3>
<p className="text-sm text-slate-600 mb-5">{t.confirmDelete}</p>
{formError && <p className="text-sm text-red-600 mb-3">{formError}</p>}
<div className={`flex gap-3 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={() => setDeleteTarget(null)} className="btn-secondary">{t.cancel}</button>
<button type="button" onClick={handleDelete} disabled={deleting} className="btn-danger">
{deleting ? t.deleting : t.delete}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,366 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OnlineReservation {
id: string
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
dailyRate: number
notes: string | null
cancelReason: string | null
createdAt: string
vehicle: { make: string; model: string; year: number; licensePlate: string }
customer: { firstName: string; lastName: string; email: string; phone: string | null }
bookingRequest: {
stage: string
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
const STATUS_STYLE: Record<string, string> = {
DRAFT: 'bg-orange-100 text-orange-800',
CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
ACTIVE: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-slate-100 text-slate-600',
NO_SHOW: 'bg-orange-100 text-orange-700',
}
const STATUS_LABEL: Record<string, string> = {
DRAFT: 'Pending',
CONFIRMED: 'Confirmed',
CANCELLED: 'Declined',
ACTIVE: 'Active',
COMPLETED: 'Completed',
NO_SHOW: 'No show',
}
const REQUEST_STAGE_STYLE: Record<string, string> = {
REQUEST_SENT: 'bg-orange-100 text-orange-800',
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
}
const REQUEST_STAGE_LABEL: Record<string, string> = {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
}
const MISSING_ITEM_LABEL: Record<string, string> = {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
}
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
const [reason, setReason] = useState('')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4" onClick={onCancel}>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason it will be visible in the reservation record.</p>
<textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Vehicle not available for these dates"
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
/>
<div className="mt-4 flex gap-3">
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
<button
disabled={loading}
onClick={() => onConfirm(reason)}
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{loading ? 'Declining…' : 'Decline'}
</button>
</div>
</div>
</div>
)
}
export default function OnlineReservationsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Online Reservations',
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
refresh: 'Refresh',
pendingApproval: 'Pending approval',
loading: 'Loading…',
allCaughtUp: 'All caught up — no pending requests.',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
status: 'Status',
total: 'Total',
},
fr: {
title: 'Réservations en ligne',
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
refresh: 'Actualiser',
pendingApproval: 'En attente de validation',
loading: 'Chargement…',
allCaughtUp: 'Tout est à jour. Aucune demande en attente.',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
status: 'Statut',
total: 'Total',
},
ar: {
title: 'الحجوزات الإلكترونية',
subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.',
refresh: 'تحديث',
pendingApproval: 'في انتظار الموافقة',
loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
status: 'الحالة',
total: 'الإجمالي',
},
}[language]
const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
setRows(
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservations')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
async function confirm(id: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to confirm reservation')
} finally {
setActing(null)
}
}
async function decline(id: string, reason: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
setDeclineTarget(null)
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to decline reservation')
} finally {
setActing(null)
}
}
const pending = rows
return (
<div className="space-y-8">
{declineTarget && (
<DeclineModal
loading={acting === declineTarget}
onConfirm={(reason) => decline(declineTarget, reason)}
onCancel={() => setDeclineTarget(null)}
/>
)}
<div className="rdg-page-heading flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
{pending.length > 0 && (
<span className="rounded-full bg-orange-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
{copy.refresh}
</button>
</div>
{actionError && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
)}
{/* Pending section */}
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> {copy.pendingApproval}
{pending.length > 0 && <span className="ml-1 text-orange-600">({pending.length})</span>}
</h3>
{loading ? (
<div className="card p-8 text-center text-sm text-slate-400">{copy.loading}</div>
) : pending.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-10 text-center">
<CheckCircle className="h-10 w-10 text-emerald-400" />
<p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
</div>
) : (
<div className="space-y-4">
{pending.map((r) => (
<ReservationCard
key={r.id}
r={r}
acting={acting === r.id}
onConfirm={() => confirm(r.id)}
onDecline={() => setDeclineTarget(r.id)}
/>
))}
</div>
)}
</section>
</div>
)
}
function ReservationCard({ r, acting, onConfirm, onDecline }: {
r: OnlineReservation
acting: boolean
onConfirm: () => void
onDecline: () => void
}) {
const days = r.totalDays
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
return (
<div className="card overflow-hidden border-l-4 border-l-orange-400">
<div className="p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
{/* Left: customer + vehicle + dates */}
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
<div>
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">
{dayjs(r.startDate).format('MMM D, YYYY')} {dayjs(r.endDate).format('MMM D, YYYY')}
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
</p>
</div>
{r.notes && (
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
</div>
)}
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
</span>
{r.bookingRequest.paymentRequired ? (
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
Payment pending
</span>
) : null}
</div>
{missingItems.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{missingItems.map((item) => (
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
{item}
</span>
))}
</div>
) : null}
</div>
</div>
{/* Right: total + actions */}
<div className="flex flex-col items-end gap-3 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-slate-400">Estimated total</p>
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
</div>
<div className="flex gap-2">
<button
onClick={onDecline}
disabled={acting}
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
Decline
</button>
<button
onClick={onConfirm}
disabled={acting}
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle className="h-4 w-4" />
{acting ? 'Confirming…' : 'Confirm'}
</button>
</div>
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
</div>
</div>
</div>
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
<Link href={`/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
View full detail <ChevronRight className="h-3 w-3" />
</Link>
</div>
</div>
)
}
+499
View File
@@ -0,0 +1,499 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp, MapPin } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types'
/**
* Location breakdown item — requires backend field on /analytics/dashboard.
* Flagged as a contract change (not faked).
*/
interface LocationBreakdown {
name: string
bookingCount: number
revenue: number
/** 0100 utilization percentage */
utilizationPct: number
}
interface DashboardData {
kpis: {
totalBookings: number
activeVehicles: number
monthlyRevenue: number
totalCustomers: number
bookingsChange: number
vehiclesChange: number
revenueChange: number
customersChange: number
/** Requires backend field — flagged as contract change */
utilizationRate?: number
/** Requires backend field — flagged as contract change */
revenuePerVehicle?: number
/** Requires backend field — flagged as contract change */
fulfillmentRate?: number
}
recentReservations: {
id: string
bookingRef: string
customerName: string
vehicleName: string
startDate: string
endDate: string
status: string
totalAmount: number
}[]
sourceBreakdown: { source: string; count: number; revenue: number }[]
/** Requires backend field — flagged as contract change */
locationBreakdown?: LocationBreakdown[]
subscription: {
status: string
planName: string
trialEndsAt: string | null
}
}
interface EmployeeProfile {
role?: string
firstName?: string
lastName?: string
email?: string
}
const STATUS_COLORS: Record<string, string> = {
CONFIRMED: 'badge-blue',
PENDING: 'badge-amber',
ACTIVE: 'badge-green',
COMPLETED: 'badge-gray',
CANCELLED: 'badge-red',
}
interface QuickAction {
icon: React.ReactNode
label: string
href: string
color: string
}
function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
return (
<div className="flex flex-wrap gap-3">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className={`rdg-quick-action flex items-center gap-2 px-4 py-3 transition-all ${action.color}`}
>
{action.icon}
<span className="text-sm font-medium">{action.label}</span>
</Link>
))}
</div>
)
}
function SubscriptionBanner({
status,
trialEndsAt,
trialEndsMsg,
trialActiveMsg,
pastDueMsg,
suspendedMsg,
manageBillingLabel,
}: {
status: string
trialEndsAt: string | null
trialEndsMsg: (date: string) => string
trialActiveMsg: string
pastDueMsg: string
suspendedMsg: string
manageBillingLabel: string
}) {
if (status === 'ACTIVE') return null
const localeDate = trialEndsAt
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: {
bg: 'bg-blue-500/10 border-blue-400/25 text-blue-200',
icon: <Clock className="w-4 h-4" />,
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
},
PAST_DUE: {
bg: 'bg-orange-500/10 border-orange-400/25 text-orange-200',
icon: <AlertTriangle className="w-4 h-4" />,
message: pastDueMsg,
},
SUSPENDED: {
bg: 'bg-red-500/10 border-red-400/25 text-red-200',
icon: <XCircle className="w-4 h-4" />,
message: suspendedMsg,
},
}
const config = configs[status]
if (!config) return null
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
{config.icon}
<p className="text-sm font-medium">{config.message}</p>
<Link href="/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</Link>
</div>
)
}
function BranchPerformanceList({
locations,
formatCurrencyFn,
}: {
locations: LocationBreakdown[]
formatCurrencyFn: (amount: number, currency: string) => string
}) {
const maxCount = Math.max(...locations.map((l) => l.bookingCount), 1)
return (
<div className="glass-card p-6">
<div className="mb-4 flex items-center gap-2">
<MapPin className="h-4 w-4 text-orange-700 dark:text-orange-300" />
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">Location Performance</h2>
</div>
<div className="space-y-4">
{locations.map((loc) => (
<div key={loc.name}>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-blue-950 dark:text-slate-100">{loc.name}</span>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.bookingCount} bookings · {formatCurrencyFn(loc.revenue, 'MAD')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill blue"
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.utilizationPct}%
</span>
</div>
</div>
))}
</div>
</div>
)
}
export default function DashboardPage() {
const { dict, language, theme } = useDashboardI18n()
const d = dict.dashboardPage
const nav = dict.nav
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const isDark = theme === 'dark'
const chartGrid = isDark ? 'rgba(59,130,246,0.14)' : 'rgba(37,99,235,0.14)'
const chartAxis = isDark ? 'rgba(59,130,246,0.20)' : 'rgba(37,99,235,0.22)'
const chartText = isDark ? '#8899b4' : '#64748b'
const chartTooltipBg = isDark ? '#111827' : '#ffffff'
const chartTooltipText = isDark ? '#e8edf5' : '#172554'
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
const [employeeName, setEmployeeName] = useState<string | null>(null)
useEffect(() => {
try {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null)
const fullName = [profile.firstName, profile.lastName].filter(Boolean).join(' ') || null
setEmployeeName(fullName)
} catch {}
}, [])
useEffect(() => {
apiFetch<DashboardData>('/analytics/dashboard')
.then(setData)
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return (
<div className="card p-6 text-center">
<p className="text-slate-500 text-sm dark:text-slate-400">{errorMsg}</p>
</div>
)
}
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const canViewRevenue = employeeRole !== 'AGENT'
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return (
<div className="space-y-8">
{/* Welcome Section */}
<div className="rdg-page-hero">
<p className="rdg-page-kicker mb-3">Rental operations</p>
<h1 className="mb-2 text-blue-950 dark:text-slate-50">
Welcome back{employeeName ? `, ${employeeName}` : ''}!
</h1>
<p className="rdg-page-description mb-0 text-sm">
Here's what's happening with your fleet today
</p>
</div>
{/* Quick Actions */}
<QuickActionsSection
actions={[
{
icon: <Plus className="w-4 h-4" />,
label: 'New Reservation',
href: '/reservations/new',
color: 'text-blue-700 dark:text-blue-300',
},
{
icon: <Car className="w-4 h-4" />,
label: nav.fleet,
href: '/fleet',
color: 'text-emerald-700 dark:text-emerald-300',
},
{
icon: <Users className="w-4 h-4" />,
label: nav.customers,
href: '/customers',
color: 'text-orange-700 dark:text-orange-300',
},
{
icon: <FileText className="w-4 h-4" />,
label: nav.reports,
href: '/reports',
color: 'text-orange-700 dark:text-orange-300',
},
]}
/>
{data?.subscription && (
<SubscriptionBanner
status={data.subscription.status}
trialEndsAt={data.subscription.trialEndsAt}
trialEndsMsg={d.trialEnds}
trialActiveMsg={d.trialActive}
pastDueMsg={d.pastDueMsg}
suspendedMsg={d.suspendedMsg}
manageBillingLabel={d.manageBilling}
/>
)}
{/* KPI Cards */}
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
<StatCard
title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()}
change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Calendar}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
pulse
/>
<StatCard
title={d.kpiActiveVehicles}
value={kpis.activeVehicles.toLocaleString()}
change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Car}
iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
/>
{canViewRevenue ? (
<StatCard
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
valueGradient
/>
) : null}
<StatCard
title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()}
change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Users}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
/>
</div>
{/* New metrics row — contract change flag */}
{kpis.utilizationRate !== undefined && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Utilization Rate"
value={`${kpis.utilizationRate}%`}
icon={TrendingUp}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
progressPercent={kpis.utilizationRate}
target={100}
/>
<StatCard
title="Revenue / Vehicle"
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
icon={DollarSign}
iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
/>
<StatCard
title="Fulfillment Rate"
value={`${kpis.fulfillmentRate ?? 0}%`}
icon={BarChart3}
iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
progressPercent={kpis.fulfillmentRate ?? 0}
target={100}
/>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column — 2/3: Booking Sources + Location Performance */}
<div className="lg:col-span-2 space-y-6">
<div className="card p-6">
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke={chartGrid} />
<XAxis dataKey="source" tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<YAxis tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: `1px solid ${chartAxis}`, background: chartTooltipBg, color: chartTooltipText, fontSize: '12px' }}
cursor={{ fill: 'rgba(59,130,246,0.08)' }}
/>
<Legend wrapperStyle={{ fontSize: '12px', color: chartText }} />
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#f97316" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
{d.noBookingData}
</div>
)}
</div>
{data?.locationBreakdown && data.locationBreakdown.length > 0 ? (
<BranchPerformanceList
locations={data.locationBreakdown}
formatCurrencyFn={(a, c) => formatCurrency(a, c as 'MAD')}
/>
) : null}
</div>
{/* Right column — 1/3: Quick Stats */}
<div className="card p-6">
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.quickStats}</h2>
<div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between">
<span className="text-sm capitalize text-slate-500 dark:text-slate-400">{item.source.toLowerCase()}</span>
<div className="text-right">
<span className="text-sm font-semibold text-blue-950 dark:text-slate-100">{item.count}</span>
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
</div>
</div>
))}
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
<p className="text-sm text-slate-400">{d.noData}</p>
)}
</div>
</div>
</div>
{/* Recent Reservations */}
<div className="card">
<div className="flex items-center justify-between border-b border-blue-200/70 px-6 py-4 dark:border-blue-400/10">
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">{d.recentReservations}</h2>
<Link href="/reservations" className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
{d.viewAll}
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-blue-200/70 dark:border-blue-400/10">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-200/70 dark:divide-blue-400/10">
{(data?.recentReservations ?? []).map((res) => (
<tr key={res.id} className="transition-colors hover:bg-blue-500/[0.06]">
<td className="px-6 py-3">
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
#{res.bookingRef}
</Link>
</td>
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500 dark:text-slate-400">
{formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td>
<td className="px-6 py-3">
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span>
</td>
<td className="px-6 py-3 text-right text-sm font-semibold text-blue-950 dark:text-slate-100">
{formatCurrency(res.totalAmount, 'MAD')}
</td>
</tr>
))}
{(!data?.recentReservations || data.recentReservations.length === 0) && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
{d.noReservations}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,206 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
reservationId: string
customerName: string
vehicle: string
startDate: string
endDate: string
totalAmount: number
source: string
paymentStatus: string
}
interface ReportData {
summary: {
totalReservations: number
totalRentalRevenue: number
totalDiscounts: number
totalInsurance: number
totalAdditionalDrivers: number
totalCollected: number
totalPricingRulesAdj?: number
}
rows: ReportRow[]
}
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour la comptabilité avec les totaux dassurance, de conducteurs supplémentaires et de règles tarifaires.',
exportCsv: 'Exporter en CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Encaissé',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
.then(setReport)
.catch((err) => setError(err.message))
}, [period])
async function exportCsv() {
setExporting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
credentials: 'include',
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? copy.exportCsv)
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? copy.exportCsv)
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-blue-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{copy.periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? copy.exporting : copy.exportCsv}
</button>
</div>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(report?.rows ?? []).map((row) => (
<tr key={row.reservationId}>
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,910 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
import ReservationPhotoSection from '@/components/reservations/ReservationPhotoSection'
interface ReservationDetail {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
discountAmount: number
insuranceTotal: number
additionalDriverTotal: number
pricingRulesTotal: number
paymentStatus: string
depositAmount: number
pickupLocation: string | null
returnLocation: string | null
paymentMode: string | null
notes: string | null
contractNumber: string | null
invoiceNumber: string | null
damageChargeAmount: number | null
damageChargeNote: string | null
pricingRulesApplied: { name: string; amount: number; type: string }[] | null
customer: {
firstName: string
lastName: string
email: string
phone: string | null
driverLicense: string | null
licenseImageUrl: string | null
licenseValidationStatus: string
flagged: boolean
}
vehicle: {
make: string
model: string
licensePlate: string
}
insurances: { id: string; policyName: string; totalCharge: number }[]
additionalDrivers: {
id: string
firstName: string
lastName: string
driverLicense: string
totalCharge: number
requiresApproval: boolean
approvedAt: string | null
approvalNote: string | null
}[]
workflow: {
contractGenerated: boolean
closed: boolean
closedAt: string | null
closedBy: string | null
coreEditable: boolean
returnEditable: boolean
checkInInspectionEditable: boolean
checkOutInspectionEditable: boolean
}
bookingRequest: {
stage: string
companyConfirmed: boolean
documentsRequired: boolean
paymentRequired: boolean
documentsMissing: string[]
}
}
type EditMode = 'booking' | 'return' | null
type ReservationFormState = {
startDate: string
endDate: string
pickupLocation: string
returnLocation: string
depositAmount: string
paymentMode: string
notes: string
damageChargeAmount: string
damageChargeNote: string
}
const detailCopy = {
en: {
editBooking: 'Edit booking',
saveBooking: 'Save booking',
editReturn: 'Edit return',
saveReturn: 'Save return',
cancelEdit: 'Cancel',
closeReservation: 'Close reservation',
bookingDetails: 'Booking details',
returnDetails: 'Return closing',
startLabel: 'Start date & time',
endLabel: 'End date & time',
pickupLabel: 'Pickup location',
returnLabel: 'Return location',
depositLabel: 'Deposit',
paymentModeLabel: 'Payment mode',
bookingNotesLabel: 'Booking notes',
returnChargeLabel: 'Damage charge',
returnChargeNoteLabel: 'Return note',
paymentModeEmpty: 'Not set',
noLocation: 'Not provided',
failedSave: 'Failed to save reservation',
failedClose: 'Failed to close reservation',
contractLockMessage: 'Contract generated. Booking details are locked and remain view-only until the vehicle is returned.',
activeLockMessage: 'Rental is in progress. Booking details are view-only until the vehicle is returned.',
returnLockMessage: 'Vehicle returned. Only the return section and the check-out inspection can still be edited before closing the reservation.',
closedLockMessage: 'Reservation closed. No further editing is allowed.',
closedMeta: 'Closed',
checkoutSummary: 'Check-out status',
checkoutPending: 'Waiting for return edits',
checkoutReady: 'Return section open',
checkoutClosed: 'Reservation closed',
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
bookingProgress: 'Booking progress',
paymentPending: 'Payment or deposit still pending',
stageLabels: {
REQUEST_SENT: 'Waiting for company review',
COMPANY_CONFIRMED: 'Availability confirmed',
DOCUMENTS_REQUIRED: 'Driver documents missing',
PAYMENT_REQUIRED: 'Payment or deposit pending',
BOOKING_CONFIRMED: 'Ready to proceed',
CANCELLED: 'Request closed',
},
missingItems: {
DATE_OF_BIRTH: 'Date of birth',
IDENTITY_DOCUMENT: 'Identity document',
FULL_ADDRESS: 'Full address',
DRIVER_LICENSE: 'Driver license number',
LICENSE_IMAGE: 'License image',
LICENSE_APPROVAL: 'License approval',
},
noLicenseImage: 'No license image uploaded.',
pickupPhotosTitle: 'Pickup photos',
dropoffPhotosTitle: 'Return photos',
pickupPhotosReadOnly: 'Pickup photos can be added once the reservation is confirmed.',
dropoffPhotosReadOnly: 'Return photos become available once the vehicle is checked out.',
extendBtn: 'Extend rental',
extendTitle: 'Extend rental',
extendNewEnd: 'New return date & time',
extendReason: 'Reason for extension',
extendReasonPlaceholder: 'e.g. Customer requested 2 extra days',
extendSubmit: 'Confirm extension',
extendCancel: 'Cancel',
extendFailed: 'Failed to extend reservation',
},
fr: {
editBooking: 'Modifier la réservation',
saveBooking: 'Enregistrer la réservation',
editReturn: 'Modifier le retour',
saveReturn: 'Enregistrer le retour',
cancelEdit: 'Annuler',
closeReservation: 'Clôturer la réservation',
bookingDetails: 'Détails de la réservation',
returnDetails: 'Clôture du retour',
startLabel: 'Date et heure de départ',
endLabel: 'Date et heure de retour',
pickupLabel: 'Lieu de départ',
returnLabel: 'Lieu de retour',
depositLabel: 'Dépôt',
paymentModeLabel: 'Mode de paiement',
bookingNotesLabel: 'Notes de réservation',
returnChargeLabel: 'Frais de dommage',
returnChargeNoteLabel: 'Note de retour',
paymentModeEmpty: 'Non défini',
noLocation: 'Non renseigné',
failedSave: 'Échec de la sauvegarde de la réservation',
failedClose: 'Échec de la clôture de la réservation',
contractLockMessage: 'Le contrat a été généré. Les détails de la réservation sont verrouillés jusquau retour du véhicule.',
activeLockMessage: 'La location est en cours. Les détails de la réservation sont en lecture seule jusquau retour du véhicule.',
returnLockMessage: 'Le véhicule est de retour. Seule la section de retour et linspection de retour peuvent encore être modifiées avant la clôture.',
closedLockMessage: 'La réservation est clôturée. Aucune autre modification nest autorisée.',
closedMeta: 'Clôturée',
checkoutSummary: 'Statut du retour',
checkoutPending: 'En attente des modifications de retour',
checkoutReady: 'Section retour ouverte',
checkoutClosed: 'Réservation clôturée',
checkInReadOnly: 'Linspection de départ est modifiable après confirmation et avant le retour.',
checkOutReadOnly: 'Linspection de retour devient modifiable une fois le véhicule retourné.',
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection sont désactivées.',
licenseImageLabel: 'Image du permis',
bookingProgress: 'Progression de la réservation',
paymentPending: 'Paiement ou dépôt encore en attente',
stageLabels: {
REQUEST_SENT: 'En attente de validation',
COMPANY_CONFIRMED: 'Disponibilité confirmée',
DOCUMENTS_REQUIRED: 'Documents conducteur manquants',
PAYMENT_REQUIRED: 'Paiement ou dépôt en attente',
BOOKING_CONFIRMED: 'Prêt à avancer',
CANCELLED: 'Demande clôturée',
},
missingItems: {
DATE_OF_BIRTH: 'Date de naissance',
IDENTITY_DOCUMENT: 'Pièce didentité',
FULL_ADDRESS: 'Adresse complète',
DRIVER_LICENSE: 'Numéro du permis',
LICENSE_IMAGE: 'Image du permis',
LICENSE_APPROVAL: 'Validation du permis',
},
noLicenseImage: 'Aucune image de permis téléversée.',
pickupPhotosTitle: 'Photos de départ',
dropoffPhotosTitle: 'Photos de retour',
pickupPhotosReadOnly: 'Les photos de départ sont disponibles après confirmation de la réservation.',
dropoffPhotosReadOnly: 'Les photos de retour sont disponibles après la restitution du véhicule.',
extendBtn: 'Prolonger la location',
extendTitle: 'Prolonger la location',
extendNewEnd: 'Nouvelle date de retour',
extendReason: 'Motif de prolongation',
extendReasonPlaceholder: 'ex. Le client a demandé 2 jours supplémentaires',
extendSubmit: 'Confirmer la prolongation',
extendCancel: 'Annuler',
extendFailed: 'Échec de la prolongation',
},
ar: {
editBooking: 'تعديل الحجز',
saveBooking: 'حفظ الحجز',
editReturn: 'تعديل الإرجاع',
saveReturn: 'حفظ الإرجاع',
cancelEdit: 'إلغاء',
closeReservation: 'إغلاق الحجز',
bookingDetails: 'تفاصيل الحجز',
returnDetails: 'إقفال الإرجاع',
startLabel: 'تاريخ ووقت البداية',
endLabel: 'تاريخ ووقت النهاية',
pickupLabel: 'موقع الاستلام',
returnLabel: 'موقع الإرجاع',
depositLabel: 'العربون',
paymentModeLabel: 'طريقة الدفع',
bookingNotesLabel: 'ملاحظات الحجز',
returnChargeLabel: 'رسوم الأضرار',
returnChargeNoteLabel: 'ملاحظة الإرجاع',
paymentModeEmpty: 'غير محدد',
noLocation: 'غير متوفر',
failedSave: 'فشل حفظ الحجز',
failedClose: 'فشل إغلاق الحجز',
contractLockMessage: 'تم إنشاء العقد. تفاصيل الحجز مقفلة وتبقى للعرض فقط حتى عودة السيارة.',
activeLockMessage: 'التأجير قيد التنفيذ. تفاصيل الحجز للعرض فقط حتى عودة السيارة.',
returnLockMessage: 'تمت إعادة السيارة. يمكن تعديل قسم الإرجاع وفحص الإرجاع فقط قبل إغلاق الحجز.',
closedLockMessage: 'تم إغلاق الحجز. لا يُسمح بأي تعديل إضافي.',
closedMeta: 'مغلق',
checkoutSummary: 'حالة الإرجاع',
checkoutPending: 'بانتظار تعديلات الإرجاع',
checkoutReady: 'قسم الإرجاع مفتوح',
checkoutClosed: 'تم إغلاق الحجز',
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
bookingProgress: 'تقدم الحجز',
paymentPending: 'الدفع أو العربون ما زال معلقاً',
stageLabels: {
REQUEST_SENT: 'بانتظار مراجعة الشركة',
COMPANY_CONFIRMED: 'تم تأكيد التوفر',
DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة',
PAYMENT_REQUIRED: 'الدفع أو العربون معلق',
BOOKING_CONFIRMED: 'جاهز للمتابعة',
CANCELLED: 'تم إغلاق الطلب',
},
missingItems: {
DATE_OF_BIRTH: 'تاريخ الميلاد',
IDENTITY_DOCUMENT: 'وثيقة الهوية',
FULL_ADDRESS: 'العنوان الكامل',
DRIVER_LICENSE: 'رقم الرخصة',
LICENSE_IMAGE: 'صورة الرخصة',
LICENSE_APPROVAL: 'اعتماد الرخصة',
},
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
pickupPhotosReadOnly: 'تتوفر صور الاستلام بعد تأكيد الحجز.',
dropoffPhotosReadOnly: 'تتوفر صور الإرجاع بعد إعادة السيارة.',
extendBtn: 'تمديد التأجير',
extendTitle: 'تمديد التأجير',
extendNewEnd: 'تاريخ الإرجاع الجديد',
extendReason: 'سبب التمديد',
extendReasonPlaceholder: 'مثال: طلب العميل يومين إضافيين',
extendSubmit: 'تأكيد التمديد',
extendCancel: 'إلغاء',
extendFailed: 'فشل تمديد الحجز',
},
} as const
function toDateTimeInputValue(iso: string) {
const date = new Date(iso)
const offset = date.getTimezoneOffset()
return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 16)
}
function toFormState(reservation: ReservationDetail): ReservationFormState {
return {
startDate: toDateTimeInputValue(reservation.startDate),
endDate: toDateTimeInputValue(reservation.endDate),
pickupLocation: reservation.pickupLocation ?? '',
returnLocation: reservation.returnLocation ?? '',
depositAmount: String(reservation.depositAmount ?? 0),
paymentMode: reservation.paymentMode ?? '',
notes: reservation.notes ?? '',
damageChargeAmount: reservation.damageChargeAmount !== null && reservation.damageChargeAmount !== undefined ? String(reservation.damageChargeAmount) : '',
damageChargeNote: reservation.damageChargeNote ?? '',
}
}
function toIsoString(value: string) {
return new Date(value).toISOString()
}
export default function ReservationDetailPage() {
const params = useParams<{ id: string }>()
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const copy = detailCopy[language]
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
const [inspections, setInspections] = useState<DamageInspection[]>([])
const [form, setForm] = useState<ReservationFormState | null>(null)
const [editMode, setEditMode] = useState<EditMode>(null)
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [acting, setActing] = useState(false)
const [showExtend, setShowExtend] = useState(false)
const [extendForm, setExtendForm] = useState({ newEndDate: '', reason: '' })
const [extendError, setExtendError] = useState<string | null>(null)
async function loadReservation() {
try {
const [reservationData, inspectionData] = await Promise.all([
apiFetch<ReservationDetail>(`/reservations/${params.id}`),
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
])
setReservation(reservationData)
setInspections(inspectionData)
setForm(toFormState(reservationData))
setEditMode(null)
setError(null)
} catch (err: any) {
setError(err.message ?? r.failedToLoad)
}
}
useEffect(() => {
loadReservation()
}, [params.id])
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true)
setActionError(null)
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
try {
await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? errorMsg)
} finally {
setActing(false)
}
}
async function saveReservation(mode: Exclude<EditMode, null>) {
if (!form) return
setActing(true)
setActionError(null)
try {
const payload = mode === 'booking'
? {
startDate: toIsoString(form.startDate),
endDate: toIsoString(form.endDate),
pickupLocation: form.pickupLocation || null,
returnLocation: form.returnLocation || null,
depositAmount: Number(form.depositAmount || 0),
paymentMode: form.paymentMode || null,
notes: form.notes || null,
}
: {
returnLocation: form.returnLocation || null,
damageChargeAmount: form.damageChargeAmount ? Number(form.damageChargeAmount) : 0,
damageChargeNote: form.damageChargeNote || null,
}
await apiFetch(`/reservations/${params.id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? copy.failedSave)
} finally {
setActing(false)
}
}
async function closeReservation() {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/close`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? copy.failedClose)
} finally {
setActing(false)
}
}
async function approveDriver(driverId: string) {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, {
method: 'PATCH',
body: JSON.stringify({ approved: true }),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? r.failedApproveDriver)
} finally {
setActing(false)
}
}
async function submitExtend() {
if (!extendForm.newEndDate || !extendForm.reason.trim()) return
setActing(true)
setExtendError(null)
try {
await apiFetch(`/reservations/${params.id}/extend`, {
method: 'POST',
body: JSON.stringify({ newEndDate: new Date(extendForm.newEndDate).toISOString(), reason: extendForm.reason }),
})
setShowExtend(false)
setExtendForm({ newEndDate: '', reason: '' })
await loadReservation()
} catch (err: any) {
setExtendError(err.message ?? copy.extendFailed)
} finally {
setActing(false)
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation || !form) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
const bookingInputsDisabled = editMode !== 'booking'
const returnInputsDisabled = editMode !== 'return'
let workflowMessage = ''
if (reservation.workflow.closed) workflowMessage = copy.closedLockMessage
else if (reservation.workflow.returnEditable) workflowMessage = copy.returnLockMessage
else if (reservation.workflow.contractGenerated) workflowMessage = copy.contractLockMessage
else if (reservation.status === 'ACTIVE') workflowMessage = copy.activeLockMessage
const checkInReadOnlyMessage = reservation.workflow.closed
? copy.inspectionClosed
: copy.checkInReadOnly
const checkOutReadOnlyMessage = reservation.workflow.closed
? copy.inspectionClosed
: copy.checkOutReadOnly
return (
<div className="space-y-6">
<div className="rdg-page-heading flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reservation {reservation.id.slice(-8).toUpperCase()}</h2>
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
{reservation.workflow.closedAt && (
<p className="mt-2 text-xs text-slate-500">
{copy.closedMeta} {formatDate(reservation.workflow.closedAt)}{reservation.workflow.closedBy ? ` · ${reservation.workflow.closedBy}` : ''}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
{editMode === null && reservation.workflow.coreEditable && (
<button disabled={acting} onClick={() => setEditMode('booking')} className="btn-secondary">
{copy.editBooking}
</button>
)}
{editMode === 'booking' && (
<>
<button disabled={acting} onClick={() => saveReservation('booking')} className="btn-primary">
{acting ? r.working : copy.saveBooking}
</button>
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
{copy.cancelEdit}
</button>
</>
)}
{editMode === null && reservation.workflow.returnEditable && (
<>
<button disabled={acting} onClick={() => setEditMode('return')} className="btn-secondary">
{copy.editReturn}
</button>
<button disabled={acting} onClick={closeReservation} className="btn-primary">
{acting ? r.working : copy.closeReservation}
</button>
</>
)}
{editMode === 'return' && (
<>
<button disabled={acting} onClick={() => saveReservation('return')} className="btn-primary">
{acting ? r.working : copy.saveReturn}
</button>
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
{copy.cancelEdit}
</button>
</>
)}
{reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? r.working : r.confirmReservation}
</button>
)}
{reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? r.working : r.checkInVehicle}
</button>
)}
{reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{acting ? r.working : r.checkOutVehicle}
</button>
)}
{['CONFIRMED', 'ACTIVE'].includes(reservation.status) && !reservation.workflow.closed && (
<button disabled={acting} onClick={() => { setShowExtend(true); setExtendError(null) }} className="btn-secondary">
{copy.extendBtn}
</button>
)}
</div>
</div>
{showExtend && (
<div className="card border border-blue-200 bg-blue-50 p-5 space-y-4">
<h4 className="text-sm font-semibold text-slate-900">{copy.extendTitle}</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendNewEnd}</label>
<input
type="datetime-local"
className="input-field"
value={extendForm.newEndDate}
onChange={(e) => setExtendForm((f) => ({ ...f, newEndDate: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendReason}</label>
<input
className="input-field"
placeholder={copy.extendReasonPlaceholder}
value={extendForm.reason}
onChange={(e) => setExtendForm((f) => ({ ...f, reason: e.target.value }))}
/>
</div>
</div>
{extendError && <p className="text-xs text-red-600">{extendError}</p>}
<div className="flex gap-3">
<button disabled={acting} onClick={submitExtend} className="btn-primary text-sm">
{acting ? r.working : copy.extendSubmit}
</button>
<button onClick={() => { setShowExtend(false); setExtendForm({ newEndDate: '', reason: '' }) }} className="btn-secondary text-sm">
{copy.extendCancel}
</button>
</div>
</div>
)}
{workflowMessage && (
<div className="card border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
{workflowMessage}
</div>
)}
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
{reservation.source === 'MARKETPLACE' ? (
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingProgress}</h3>
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">
{copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage}
</span>
{reservation.bookingRequest.paymentRequired ? (
<span className="rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
{copy.paymentPending}
</span>
) : null}
</div>
{reservation.bookingRequest.documentsMissing.length > 0 ? (
<div className="mt-4 flex flex-wrap gap-2">
{reservation.bookingRequest.documentsMissing.map((item) => (
<span key={item} className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-800">
{copy.missingItems[item as keyof typeof copy.missingItems] ?? item}
</span>
))}
</div>
) : null}
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
<div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="pt-2">
<p className="text-slate-600">{copy.licenseImageLabel}</p>
{reservation.customer.licenseImageUrl ? (
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
<img
src={reservation.customer.licenseImageUrl}
alt="Driver license"
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
/>
</a>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
</div>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
</div>
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p>
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
<p>{copy.paymentModeLabel}: <span className="font-medium text-slate-900">{reservation.paymentMode || copy.paymentModeEmpty}</span></p>
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingDetails}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startLabel}</label>
<input
type="datetime-local"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.startDate}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, startDate: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endLabel}</label>
<input
type="datetime-local"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.endDate}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, endDate: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.pickupLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.pickupLocation}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, pickupLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.returnLocation}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.depositLabel}</label>
<input
type="number"
min="0"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.depositAmount}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, depositAmount: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paymentModeLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.paymentMode}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)}
/>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.bookingNotesLabel}</label>
<textarea
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.notes}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, notes: e.target.value } : current)}
/>
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="PICKUP"
editable={reservation.workflow.checkInInspectionEditable}
title={copy.pickupPhotosTitle}
readOnlyMessage={copy.pickupPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.returnDetails}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.returnLocation}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeLabel}</label>
<input
type="number"
min="0"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.damageChargeAmount}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, damageChargeAmount: e.target.value } : current)}
/>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeNoteLabel}</label>
<textarea
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.damageChargeNote}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, damageChargeNote: e.target.value } : current)}
/>
</div>
<div className="mt-4 rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span className="font-medium text-slate-900">{copy.checkoutSummary}:</span>{' '}
{reservation.workflow.closed
? copy.checkoutClosed
: reservation.workflow.returnEditable
? copy.checkoutReady
: copy.checkoutPending}
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="DROPOFF"
editable={reservation.workflow.checkOutInspectionEditable}
title={copy.dropoffPhotosTitle}
readOnlyMessage={copy.dropoffPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{copy.returnChargeLabel}</dt><dd className="text-slate-900">{formatCurrency(reservation.damageChargeAmount ?? 0, 'MAD')}</dd></div>
</dl>
{reservation.insurances.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
<div className="space-y-2">
{reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{insurance.policyName}</span>
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
</div>
))}
</div>
</div>
)}
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
<div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{rule.name}</span>
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-orange-700'}>
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
</span>
</div>
))}
</div>
</div>
)}
</div>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKIN"
initialInspection={checkinInspection}
editable={reservation.workflow.checkInInspectionEditable}
readOnlyMessage={checkInReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKOUT"
initialInspection={checkoutInspection}
comparisonPoints={checkinInspection?.damagePoints ?? []}
editable={reservation.workflow.checkOutInspectionEditable}
readOnlyMessage={checkOutReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
</div>
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
<div className="space-y-3">
{reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div>
{driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-orange-600 px-4 py-2 text-xs font-semibold text-white">
{r.approveBtn}
</button>
) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
</span>
)}
</div>
{driver.approvalNote && <p className="mt-2 text-xs text-orange-700">{driver.approvalNote}</p>}
</div>
))}
{reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
)}
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{copy.returnLabel}</span>
<span className="font-medium text-slate-900">{reservation.returnLocation ?? copy.noLocation}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,808 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type Customer = {
id: string
firstName: string
lastName: string
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type AdditionalDriverForm = {
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
type CreatedReservation = { id: string }
export default function NewReservationPage() {
const { language } = useDashboardI18n()
const router = useRouter()
const [customers, setCustomers] = useState<Customer[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [customerId, setCustomerId] = useState('')
const [customerSearch, setCustomerSearch] = useState('')
const [vehicleId, setVehicleId] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [pickupLocation, setPickupLocation] = useState('')
const [returnLocation, setReturnLocation] = useState('')
const [depositAmount, setDepositAmount] = useState('0')
const [paymentMode, setPaymentMode] = useState('CASH')
const [notes, setNotes] = useState('')
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [savingCustomer, setSavingCustomer] = useState(false)
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
const [customerNationality, setCustomerNationality] = useState('')
const [customerFullAddress, setCustomerFullAddress] = useState('')
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
})
const copy = {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
customer: 'Customer',
searchCustomer: 'Search previous customer',
vehicle: 'Vehicle',
startDate: 'Start date & time',
endDate: 'End date & time',
pickup: 'Pickup location',
return: 'Return location',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
renterIdentity: 'Renter identity',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
notes: 'Notes',
notesPlaceholder: 'Optional notes…',
create: 'Create booking',
creating: 'Creating…',
cancel: 'Cancel',
loadFailed: 'Failed to load customers/vehicles.',
invalidDates: 'End date must be after start date.',
required: 'Please fill all required fields.',
selectCustomer: 'Select customer…',
addCustomer: 'Add customer',
addCustomerTitle: 'Add new customer',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
saveCustomer: 'Save customer',
savingCustomer: 'Saving customer…',
selectVehicle: 'Select vehicle…',
noVehicles: 'No available vehicles.',
paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
fr: {
title: 'Réservation locale',
subtitle: 'Créez une réservation directement depuis lespace, sans réservation en ligne préalable.',
customer: 'Client',
searchCustomer: 'Rechercher un client existant',
vehicle: 'Véhicule',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
renterIdentity: 'Identité du locataire',
driverLicenseInfo: 'Informations du permis',
driverLicense: 'Numéro du permis',
licenseExpiry: 'Expiration du permis',
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles…',
create: 'Créer la réservation',
creating: 'Création…',
cancel: 'Annuler',
loadFailed: 'Échec du chargement des clients/véhicules.',
invalidDates: 'La date de fin doit être après la date de début.',
required: 'Veuillez remplir tous les champs requis.',
selectCustomer: 'Sélectionner un client…',
addCustomer: 'Ajouter un client',
addCustomerTitle: 'Ajouter un nouveau client',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
saveCustomer: 'Enregistrer le client',
savingCustomer: 'Enregistrement du client…',
selectVehicle: 'Sélectionner un véhicule…',
noVehicles: 'Aucun véhicule disponible.',
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
customer: 'العميل',
searchCustomer: 'ابحث عن عميل سابق',
vehicle: 'المركبة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
renterIdentity: 'بيانات المستأجر',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية…',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء…',
cancel: 'إلغاء',
loadFailed: 'فشل تحميل العملاء/المركبات.',
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
selectCustomer: 'اختر عميلًا…',
addCustomer: 'إضافة عميل',
addCustomerTitle: 'إضافة عميل جديد',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
saveCustomer: 'حفظ العميل',
savingCustomer: 'جارٍ حفظ العميل…',
selectVehicle: 'اختر مركبة…',
noVehicles: 'لا توجد مركبات متاحة.',
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
useEffect(() => {
Promise.all([
apiFetch<Customer[]>('/customers?pageSize=100'),
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
])
.then(([c, v]) => {
setCustomers(c ?? [])
setVehicles(v ?? [])
})
.catch((err) => setError(err.message ?? copy.loadFailed))
.finally(() => setLoading(false))
}, [])
const filteredCustomers = (() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q),
)
})()
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE')
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
setCustomerNationality(selected.nationality ?? '')
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers])
useEffect(() => {
if (!licenseImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(licenseImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [licenseImageFile])
async function uploadLicenseImage(customerIdToUpdate: string) {
if (!licenseImageFile) return null
const formData = new FormData()
formData.append('file', licenseImageFile)
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
method: 'POST',
body: formData,
})
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
setLicenseImageUrl(updated.licenseImageUrl ?? null)
setLicenseImageFile(null)
return updated.licenseImageUrl ?? null
}
async function addCustomer() {
setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
setError(copy.required)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(),
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
lastName: bilingualPrimary(newCustomerLastName).trim(),
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
setShowAddCustomer(false)
setNewCustomerFirstName(emptyBilingual())
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) {
setError(err.message)
} finally {
setSavingCustomer(false)
}
}
async function submit() {
setError(null)
if (!canSubmit) {
setError(copy.required)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(copy.invalidDates)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
}
setSaving(true)
try {
await apiFetch(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify({
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify({
customerId,
vehicleId,
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: returnLocation || undefined,
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
paymentMode,
additionalDrivers: includeAdditionalDriver ? [{
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
})
router.push(`/reservations/${created.id}`)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6 max-w-3xl">
<div className="rdg-page-heading flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
<div className="card p-6 space-y-4">
{loading ? (
<p className="text-sm text-slate-500">Loading</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<input
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
placeholder={copy.searchCustomer}
className="input-field mb-2"
/>
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="input-field">
<option value="">{copy.selectCustomer}</option>
{filteredCustomers.map((c) => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} · {c.email}</option>
))}
</select>
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:underline mt-1"
onClick={() => setShowAddCustomer((v) => !v)}
>
{copy.addCustomer}
</button>
{showAddCustomer ? (
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
</div>
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
</div>
<div className="flex justify-end">
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
</button>
</div>
</div>
) : null}
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.make} {v.model} · {v.licensePlate}</option>
))}
</select>
{vehicles.length === 0 ? (
<span className="text-xs text-orange-700">{copy.noVehicles}</span>
) : availableVehicles.length === 0 ? (
<span className="text-xs text-orange-700">
{language === 'fr'
? 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.'
: language === 'ar'
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
: 'Vehicles exist in fleet, but none are marked Available.'}
</span>
) : null}
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input
type="file"
accept="image/*"
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
className="input-field"
/>
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{licenseImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
) : licenseImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{licenseImagePreviewUrl || licenseImageUrl ? (
<img
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
alt="Driver license preview"
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
/>
) : null}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
</label>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
))}
</select>
</label>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={includeAdditionalDriver}
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
</label>
{includeAdditionalDriver ? (
<div className="space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
<input
value={additionalDriver.email}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
<input
value={additionalDriver.phone}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
className="input-field"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<div className="space-y-1">
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
<input
type="date"
value={additionalDriver.dateOfBirth}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
<input
type="date"
value={additionalDriver.licenseIssuedAt}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
<input
type="date"
value={additionalDriver.licenseExpiry}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
className="input-field"
/>
</label>
</div>
</div>
) : null}
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="pt-2 flex items-center justify-end gap-3">
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
{saving ? copy.creating : copy.create}
</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,196 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReservationRow {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
contractNumber?: string | null
workflow?: {
contractGenerated: boolean
closed: boolean
coreEditable: boolean
returnEditable: boolean
}
vehicle: { make: string; model: string }
customer: { firstName: string; lastName: string; email: string }
}
interface VehicleRow {
id: string
}
export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
const router = useRouter()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [rows, setRows] = useState<ReservationRow[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [checkingNewReservation, setCheckingNewReservation] = useState(false)
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setLoadError(err.message))
}, [])
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
const reservationActionLabel = (row: ReservationRow) => {
if (row.workflow?.returnEditable) {
return language === 'fr' ? 'Retour / clôture' : language === 'ar' ? 'الإرجاع / الإغلاق' : 'Return / close'
}
if (row.workflow?.coreEditable) {
return language === 'fr' ? 'Modifier la réservation' : language === 'ar' ? 'تعديل الحجز' : 'Edit reservation'
}
return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation'
}
const noVehiclesMessage =
language === 'fr'
? 'Ajoutez au moins un véhicule à votre flotte avant de créer une réservation.'
: language === 'ar'
? 'أضف مركبة واحدة على الأقل إلى الأسطول قبل إنشاء حجز.'
: 'Add at least one vehicle to your fleet before creating a reservation.'
async function handleNewReservationClick() {
setActionError(null)
setCheckingNewReservation(true)
try {
const vehicles = await apiFetch<VehicleRow[]>('/vehicles?pageSize=1')
if (!vehicles || vehicles.length === 0) {
setShowNoVehiclesModal(true)
return
}
router.push('/reservations/new')
} catch (err: any) {
setActionError(err.message ?? (
language === 'fr'
? 'Impossible de vérifier la flotte avant de créer la réservation.'
: language === 'ar'
? 'تعذر التحقق من الأسطول قبل إنشاء الحجز.'
: 'Failed to verify fleet before creating the reservation.'
))
} finally {
setCheckingNewReservation(false)
}
}
function handleAddCar() {
setShowNoVehiclesModal(false)
router.push('/fleet?modal=add-car')
}
return (
<div className="space-y-6">
<div className="rdg-page-heading flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div>
<button
type="button"
className="btn-primary whitespace-nowrap"
onClick={handleNewReservationClick}
disabled={checkingNewReservation}
>
{checkingNewReservation
? language === 'fr'
? 'Vérification…'
: language === 'ar'
? 'جارٍ التحقق…'
: 'Checking…'
: language === 'fr'
? 'Réserver une voiture'
: language === 'ar'
? 'حجز سيارة'
: 'Book car'}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
<div className="card overflow-hidden">
{loadError ? (
<div className="p-8 text-sm text-red-600">{loadError}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4">
<Link href={`/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
<Link href={`/reservations/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
{reservationActionLabel(row)}
</Link>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
{showNoVehiclesModal ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4"
onClick={(event) => {
if (event.target === event.currentTarget) setShowNoVehiclesModal(false)
}}
>
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-slate-900">
{language === 'fr' ? 'Ajouter un véhicule' : language === 'ar' ? 'أضف مركبة' : 'Add a car'}
</h3>
<p className="mt-2 text-sm text-slate-600">{noVehiclesMessage}</p>
<div className="mt-6 flex gap-3">
<button type="button" className="btn-primary flex-1" onClick={handleAddCar}>
{language === 'fr' ? 'Ajouter une voiture' : language === 'ar' ? 'إضافة مركبة' : 'Add car'}
</button>
<button type="button" className="btn-secondary flex-1" onClick={() => setShowNoVehiclesModal(false)}>
{language === 'fr' ? 'Annuler' : language === 'ar' ? 'إلغاء' : 'Cancel'}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,422 @@
'use client'
import { useEffect, useState } from 'react'
import { Star, MessageSquare, AlertTriangle } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Reviews',
subtitle: 'Customer feedback for your company',
totalReviews: 'Total Reviews',
avgRating: 'Average Rating',
fiveStars: '5-Star Reviews',
negative: 'Negative Reviews',
filterRating: 'Filter by rating',
allRatings: 'All ratings',
filterDate: 'Date range',
last7: 'Last 7 days',
last30: 'Last 30 days',
last90: 'Last 90 days',
allTime: 'All time',
customer: 'Customer',
vehicle: 'Vehicle',
date: 'Date',
rating: 'Rating',
comment: 'Comment',
reply: 'Reply',
replied: 'Replied',
noReply: 'No reply yet',
saveReply: 'Save reply',
cancel: 'Cancel',
saving: 'Saving…',
noReviews: 'No reviews yet',
newComplaint: 'New complaint',
replyPlaceholder: 'Write your reply…',
errorLoad: 'Failed to load reviews',
errorReply: 'Failed to save reply',
stars: (n: number) => n === 1 ? '1 star' : `${n} stars`,
},
fr: {
heading: 'Avis',
subtitle: 'Retours clients pour votre entreprise',
totalReviews: 'Total des avis',
avgRating: 'Note moyenne',
fiveStars: 'Avis 5 étoiles',
negative: 'Avis négatifs',
filterRating: 'Filtrer par note',
allRatings: 'Toutes les notes',
filterDate: 'Période',
last7: '7 derniers jours',
last30: '30 derniers jours',
last90: '90 derniers jours',
allTime: 'Tout le temps',
customer: 'Client',
vehicle: 'Véhicule',
date: 'Date',
rating: 'Note',
comment: 'Commentaire',
reply: 'Répondre',
replied: 'Répondu',
noReply: 'Pas encore de réponse',
saveReply: 'Enregistrer',
cancel: 'Annuler',
saving: 'Enregistrement…',
noReviews: 'Aucun avis pour l\'instant',
newComplaint: 'Nouvelle plainte',
replyPlaceholder: 'Rédigez votre réponse…',
errorLoad: 'Échec du chargement des avis',
errorReply: 'Échec de l\'enregistrement de la réponse',
stars: (n: number) => n === 1 ? '1 étoile' : `${n} étoiles`,
},
ar: {
heading: 'التقييمات',
subtitle: 'آراء العملاء لشركتك',
totalReviews: 'إجمالي التقييمات',
avgRating: 'متوسط التقييم',
fiveStars: 'تقييمات 5 نجوم',
negative: 'تقييمات سلبية',
filterRating: 'تصفية حسب التقييم',
allRatings: 'جميع التقييمات',
filterDate: 'النطاق الزمني',
last7: 'آخر 7 أيام',
last30: 'آخر 30 يوماً',
last90: 'آخر 90 يوماً',
allTime: 'كل الوقت',
customer: 'العميل',
vehicle: 'المركبة',
date: 'التاريخ',
rating: 'التقييم',
comment: 'تعليق',
reply: 'رد',
replied: 'تم الرد',
noReply: 'لم يتم الرد بعد',
saveReply: 'حفظ الرد',
cancel: 'إلغاء',
saving: 'جار الحفظ…',
noReviews: 'لا توجد تقييمات بعد',
newComplaint: 'شكوى جديدة',
replyPlaceholder: 'اكتب ردك هنا…',
errorLoad: 'فشل تحميل التقييمات',
errorReply: 'فشل حفظ الرد',
stars: (n: number) => `${n} نجوم`,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface ReviewReservation {
id: string
startDate: string
endDate: string
customer: { id: string; firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; year: number }
}
interface Review {
id: string
reservationId: string
overallRating: number
vehicleRating: number | null
serviceRating: number | null
comment: string | null
companyReply: string | null
companyRepliedAt: string | null
isPublished: boolean
createdAt: string
reservation: ReviewReservation
}
interface ReviewsResponse {
data: Review[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
interface Stats {
total: number
averageOverall: number
averageVehicle: number
averageService: number
byRating: Record<string, number>
}
// ─── Star display ──────────────────────────────────────────────────────────────
function StarDisplay({ rating, size = 'sm' }: { rating: number; size?: 'sm' | 'md' }) {
const sz = size === 'md' ? 'h-5 w-5' : 'h-4 w-4'
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`${sz} ${s <= rating ? 'fill-amber-400 text-amber-400' : 'text-stone-300 dark:text-blue-900'}`}
/>
))}
</div>
)
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ReviewsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [reviews, setReviews] = useState<Review[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterRating, setFilterRating] = useState<string>('')
const [filterDays, setFilterDays] = useState<string>('')
const [page, setPage] = useState(1)
const [replyingId, setReplyingId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replySaving, setReplySaving] = useState(false)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterRating) params.set('rating', filterRating)
const [reviewsData, statsData] = await Promise.all([
apiFetch<ReviewsResponse>(`/reviews?${params}`),
apiFetch<Stats>('/reviews/stats'),
])
let data = reviewsData.data ?? []
if (filterDays) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - Number(filterDays))
data = data.filter((r) => new Date(r.createdAt) >= cutoff)
}
setReviews(data)
setMeta(reviewsData.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
setStats(statsData)
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterRating, filterDays])
async function handleSaveReply(reviewId: string) {
if (!replyText.trim()) return
setReplySaving(true)
try {
await apiFetch(`/reviews/${reviewId}/reply`, {
method: 'PATCH',
body: JSON.stringify({ companyReply: replyText.trim() }),
})
setReplyingId(null)
setReplyText('')
await loadData()
} catch {
alert(t.errorReply)
} finally {
setReplySaving(false)
}
}
function startReply(review: Review) {
setReplyingId(review.id)
setReplyText(review.companyReply ?? '')
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="rdg-page-heading">
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.totalReviews}</p>
<p className="mt-2 text-3xl font-bold text-blue-950 dark:text-white">{stats.total}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.avgRating}</p>
<div className="mt-2 flex items-center gap-2">
<p className="text-3xl font-bold text-blue-950 dark:text-white">{stats.averageOverall.toFixed(1)}</p>
<StarDisplay rating={Math.round(stats.averageOverall)} size="md" />
</div>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.fiveStars}</p>
<p className="mt-2 text-3xl font-bold text-emerald-600 dark:text-emerald-400">{stats.byRating['5'] ?? 0}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.negative}</p>
<p className="mt-2 text-3xl font-bold text-red-600 dark:text-red-400">
{(stats.byRating['1'] ?? 0) + (stats.byRating['2'] ?? 0)}
</p>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterRating}</label>
<select
value={filterRating}
onChange={(e) => { setFilterRating(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allRatings}</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{t.stars(r)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterDate}</label>
<select
value={filterDays}
onChange={(e) => { setFilterDays(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allTime}</option>
<option value="7">{t.last7}</option>
<option value="30">{t.last30}</option>
<option value="90">{t.last90}</option>
</select>
</div>
</div>
</div>
{/* Reviews list */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : reviews.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noReviews}</div>
) : (
<div className="space-y-4">
{reviews.map((review) => {
const customer = review.reservation?.customer
const vehicle = review.reservation?.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : '—'
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : '—'
const isReplying = replyingId === review.id
const isNegative = review.overallRating <= 2
return (
<div key={review.id} className={`card p-5 ${isNegative ? 'border-red-200 dark:border-red-900/40' : ''}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<span className="font-semibold text-blue-950 dark:text-white">{customerName}</span>
<span className="text-sm text-stone-400 dark:text-slate-500">{vehicleLabel}</span>
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(review.createdAt).toLocaleDateString()}
</span>
</div>
<StarDisplay rating={review.overallRating} />
{review.comment && (
<p className="mt-2 line-clamp-3 text-sm text-stone-600 dark:text-slate-300">{review.comment}</p>
)}
{review.companyReply && !isReplying && (
<div className="mt-3 rounded-2xl border border-blue-100 bg-blue-50 p-3 dark:border-blue-900/40 dark:bg-blue-950/30">
<div className="mb-1 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">{t.replied}</span>
</div>
<p className="text-sm text-stone-600 dark:text-slate-300">{review.companyReply}</p>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
onClick={() => startReply(review)}
className="btn-secondary text-xs"
>
<MessageSquare className="h-3.5 w-3.5" />
{review.companyReply ? t.replied : t.reply}
</button>
<a
href={`/complaints?reservationId=${review.reservation?.id ?? ''}`}
className="btn-secondary text-xs"
>
<AlertTriangle className="h-3.5 w-3.5" />
{t.newComplaint}
</a>
</div>
</div>
{/* Inline reply form */}
{isReplying && (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t.replyPlaceholder}
rows={3}
className="input-field resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveReply(review.id)}
disabled={replySaving || !replyText.trim()}
className="btn-primary text-sm"
>
{replySaving ? t.saving : t.saveReply}
</button>
<button
onClick={() => { setReplyingId(null); setReplyText('') }}
className="btn-secondary text-sm"
>
{t.cancel}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,830 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface BrandSettings {
displayName: string
tagline: string | null
primaryColor: string
accentColor?: string | null
publicEmail: string | null
publicPhone: string | null
publicAddress?: string | null
publicCity: string | null
publicCountry?: string | null
subdomain: string
logoUrl?: string | null
heroImageUrl?: string | null
websiteUrl?: string | null
whatsappNumber?: string | null
paypalEmail: string | null
amanpayMerchantId: string | null
amanpaySecretKey?: string | null
paypalMerchantId?: string | null
customDomain?: string | null
customDomainVerified?: boolean
defaultLocale?: string
defaultCurrency?: string
isListedOnMarketplace: boolean
}
interface ContractSettings {
fuelPolicyType: string
fuelPolicyNote: string | null
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
additionalDriverDailyRate: number
additionalDriverFlatRate: number
damagePolicy: string
}
interface InsurancePolicy {
id: string
name: string
type: string
chargeType: string
chargeValue: number
isRequired: boolean
isActive: boolean
}
interface PricingRule {
id: string
name: string
type: string
condition: string
conditionValue: number
adjustmentType: string
adjustmentValue: number
isActive: boolean
}
interface AccountingSettings {
reportingPeriod: string
accountantEmail: string | null
accountantName: string | null
autoSendReport: boolean
reportFormat: string
}
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Advanced settings',
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
save: 'Saving…',
saveBrand: 'Save brand',
brandProfile: 'Brand and public profile',
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments',
customDomain: 'Custom domain',
customDomainDesc: 'Point your own domain to the branded booking site.',
saveDomain: 'Save domain',
removeDomain: 'Remove custom domain',
contractPolicies: 'Contract and driver policies',
savePolicies: 'Save policies',
insurancePolicies: 'Insurance policies',
pricingRules: 'Pricing rules',
accounting: 'Accounting and exports',
saveAccounting: 'Save accounting',
displayName: 'Display name',
tagline: 'Tagline',
publicEmail: 'Public email',
publicPhone: 'Public phone',
city: 'City',
country: 'Country',
websiteUrl: 'Website URL',
contractLanguage: 'Contract language',
whatsapp: 'WhatsApp number',
brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace',
logoUpload: 'Logo upload',
heroUpload: 'Hero image upload',
uploading: 'Uploading…',
logoUploaded: 'Logo uploaded',
noLogo: 'No logo uploaded yet',
heroUploaded: 'Hero image uploaded',
noHero: 'No hero image uploaded yet',
subdomain: 'Subdomain',
customDomainLabel: 'Custom domain',
domainPlaceholder: 'cars.example.com',
status: 'Status',
verified: 'Verified',
pendingDns: 'Pending DNS verification',
notConfigured: 'Not configured',
fuelPolicyType: 'Fuel policy type',
additionalDriverCharge: 'Additional driver charge',
dailyDriverRate: 'Daily driver rate',
flatDriverRate: 'Flat driver rate',
fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy',
active: 'Active',
inactive: 'Inactive',
noInsurance: 'No insurance policies configured yet.',
policyName: 'Policy name',
chargeValue: 'Charge value',
required: 'Required',
addPolicy: 'Add policy',
noRules: 'No pricing rules configured yet.',
ruleName: 'Rule name',
addRule: 'Add rule',
reportingPeriod: 'Reporting period',
reportFormat: 'Report format',
accountantName: 'Accountant name',
accountantEmail: 'Accountant email',
autoSend: 'Auto-send reports to the accountant',
amanpayMerchantId: 'AmanPay merchant ID',
amanpaySecretKey: 'AmanPay secret key',
paypalEmail: 'PayPal business email',
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
},
fr: {
title: 'Paramètres avancés',
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.',
payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements',
customDomain: 'Domaine personnalisé',
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
saveDomain: 'Enregistrer le domaine',
removeDomain: 'Supprimer le domaine',
contractPolicies: 'Contrat et politiques conducteur',
savePolicies: 'Enregistrer les politiques',
insurancePolicies: 'Polices dassurance',
pricingRules: 'Règles tarifaires',
accounting: 'Comptabilité et exports',
saveAccounting: 'Enregistrer la comptabilité',
displayName: 'Nom affiché',
tagline: 'Slogan',
publicEmail: 'E-mail public',
publicPhone: 'Téléphone public',
city: 'Ville',
country: 'Pays',
websiteUrl: 'URL du site',
contractLanguage: 'Langue du contrat',
whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque',
listedMarketplace: 'Publié sur la marketplace',
logoUpload: 'Logo',
heroUpload: 'Image principale',
uploading: 'Téléversement…',
logoUploaded: 'Logo téléversé',
noLogo: 'Aucun logo téléversé',
heroUploaded: 'Image principale téléversée',
noHero: 'Aucune image principale téléversée',
subdomain: 'Sous-domaine',
customDomainLabel: 'Domaine personnalisé',
domainPlaceholder: 'voitures.exemple.com',
status: 'Statut',
verified: 'Vérifié',
pendingDns: 'Vérification DNS en attente',
notConfigured: 'Non configuré',
fuelPolicyType: 'Type de politique carburant',
additionalDriverCharge: 'Supplément conducteur additionnel',
dailyDriverRate: 'Tarif conducteur/jour',
flatDriverRate: 'Tarif conducteur fixe',
fuelPolicyNote: 'Note sur la politique carburant',
damagePolicy: 'Politique dommages',
active: 'Actif',
inactive: 'Inactif',
noInsurance: 'Aucune police dassurance configurée.',
policyName: 'Nom de la police',
chargeValue: 'Valeur de facturation',
required: 'Obligatoire',
addPolicy: 'Ajouter la police',
noRules: 'Aucune règle tarifaire configurée.',
ruleName: 'Nom de la règle',
addRule: 'Ajouter la règle',
reportingPeriod: 'Période de reporting',
reportFormat: 'Format de rapport',
accountantName: 'Nom du comptable',
accountantEmail: 'E-mail du comptable',
autoSend: 'Envoyer automatiquement les rapports au comptable',
amanpayMerchantId: 'ID marchand AmanPay',
amanpaySecretKey: 'Clé secrète AmanPay',
paypalEmail: 'Email business PayPal',
noPaymentConfigured: 'Si aucun moyen de paiement nest configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
},
ar: {
title: 'الإعدادات المتقدمة',
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
save: 'جارٍ الحفظ…',
saveBrand: 'حفظ العلامة',
brandProfile: 'العلامة والملف العام',
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
payments: 'طرق دفع الإيجار',
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
savePayments: 'حفظ الدفع',
customDomain: 'نطاق مخصص',
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
saveDomain: 'حفظ النطاق',
removeDomain: 'إزالة النطاق المخصص',
contractPolicies: 'العقد وسياسات السائق',
savePolicies: 'حفظ السياسات',
insurancePolicies: 'سياسات التأمين',
pricingRules: 'قواعد التسعير',
accounting: 'المحاسبة والتصدير',
saveAccounting: 'حفظ المحاسبة',
displayName: 'اسم العرض',
tagline: 'الشعار',
publicEmail: 'البريد الإلكتروني العام',
publicPhone: 'الهاتف العام',
city: 'المدينة',
country: 'الدولة',
websiteUrl: 'رابط الموقع',
contractLanguage: 'لغة العقد',
whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق',
logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…',
logoUploaded: 'تم رفع الشعار',
noLogo: 'لم يتم رفع شعار بعد',
heroUploaded: 'تم رفع صورة الواجهة',
noHero: 'لم يتم رفع صورة واجهة بعد',
subdomain: 'النطاق الفرعي',
customDomainLabel: 'النطاق المخصص',
domainPlaceholder: 'cars.example.com',
status: 'الحالة',
verified: 'موثق',
pendingDns: 'بانتظار التحقق من DNS',
notConfigured: 'غير مُعدّ',
fuelPolicyType: 'نوع سياسة الوقود',
additionalDriverCharge: 'رسوم السائق الإضافي',
dailyDriverRate: 'تعرفة السائق اليومية',
flatDriverRate: 'تعرفة السائق الثابتة',
fuelPolicyNote: 'ملاحظة سياسة الوقود',
damagePolicy: 'سياسة الأضرار',
active: 'نشط',
inactive: 'غير نشط',
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
policyName: 'اسم السياسة',
chargeValue: 'قيمة الرسوم',
required: 'إلزامي',
addPolicy: 'إضافة سياسة',
noRules: 'لا توجد قواعد تسعير مهيأة.',
ruleName: 'اسم القاعدة',
addRule: 'إضافة قاعدة',
reportingPeriod: 'فترة التقرير',
reportFormat: 'تنسيق التقرير',
accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب',
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
amanpayMerchantId: 'معرّف التاجر AmanPay',
amanpaySecretKey: 'المفتاح السري AmanPay',
paypalEmail: 'بريد أعمال PayPal',
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
},
}[language]
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
const [newRule, setNewRule] = useState(emptyRule)
const [customDomain, setCustomDomain] = useState('')
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
async function load() {
try {
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
apiFetch<BrandSettings | null>('/companies/me/brand'),
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
])
setBrand(brandData)
setCustomDomain(brandData?.customDomain ?? '')
setContractSettings(contractData ?? {
fuelPolicyType: 'FULL_TO_FULL',
fuelPolicyNote: '',
additionalDriverCharge: 'FREE',
additionalDriverDailyRate: 0,
additionalDriverFlatRate: 0,
damagePolicy: '',
})
setInsurancePolicies(insuranceData)
setPricingRules(ruleData)
setAccountingSettings(accountingData ?? {
reportingPeriod: 'MONTHLY',
accountantEmail: '',
accountantName: '',
autoSendReport: false,
reportFormat: 'CSV',
})
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load settings')
}
}
async function saveBrandSettings() {
if (!brand) return
setSaving(true)
setError(null)
try {
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor || undefined,
publicEmail: brand.publicEmail || undefined,
publicPhone: brand.publicPhone || undefined,
publicAddress: brand.publicAddress || undefined,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
websiteUrl: brand.websiteUrl || undefined,
defaultLocale: brand.defaultLocale || undefined,
whatsappNumber: brand.whatsappNumber || undefined,
paypalEmail: brand.paypalEmail || undefined,
amanpayMerchantId: brand.amanpayMerchantId || undefined,
amanpaySecretKey: brand.amanpaySecretKey || undefined,
paypalMerchantId: brand.paypalMerchantId || undefined,
defaultCurrency: brand.defaultCurrency || undefined,
isListedOnMarketplace: brand.isListedOnMarketplace,
}),
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? 'Failed to save brand settings')
} finally {
setSaving(false)
}
}
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
if (!file) return
setUploadingAsset(kind)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
method: 'POST',
body: formData,
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? `Failed to upload ${kind}`)
} finally {
setUploadingAsset(null)
}
}
async function saveCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'POST',
body: JSON.stringify({ customDomain }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to save custom domain')
} finally {
setSaving(false)
}
}
async function removeCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'DELETE',
})
setCustomDomain('')
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to remove custom domain')
} finally {
setSaving(false)
}
}
useEffect(() => {
load()
}, [])
async function saveContractSettings() {
if (!contractSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/contract-settings', {
method: 'PATCH',
body: JSON.stringify(contractSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save contract settings')
} finally {
setSaving(false)
}
}
async function saveAccountingSettings() {
if (!accountingSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/accounting-settings', {
method: 'PATCH',
body: JSON.stringify(accountingSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save accounting settings')
} finally {
setSaving(false)
}
}
async function createInsurance() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/insurance-policies', {
method: 'POST',
body: JSON.stringify(newInsurance),
})
setNewInsurance(emptyInsurance)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create insurance policy')
} finally {
setSaving(false)
}
}
async function toggleInsurance(policy: InsurancePolicy) {
try {
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !policy.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update insurance policy')
}
}
async function createRule() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/pricing-rules', {
method: 'POST',
body: JSON.stringify(newRule),
})
setNewRule(emptyRule)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create pricing rule')
} finally {
setSaving(false)
}
}
async function toggleRule(rule: PricingRule) {
try {
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !rule.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update pricing rule')
}
}
return (
<div className="space-y-6">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{brand && (
<div className="grid gap-6">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveBrand}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.contractLanguage}</label>
<select className="input-field" value={brand.defaultLocale ?? 'en'} onChange={(event) => setBrand((current) => current ? { ...current, defaultLocale: event.target.value } : current)}>
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="ar">AR</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
{copy.listedMarketplace}
</label>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
</label>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePayments}
</button>
</div>
<div className="mt-5 grid gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.noPaymentConfigured}
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
</div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? copy.save : copy.saveDomain}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
</div>
{brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
{copy.removeDomain}
</button>
) : null}
</div>
</div>
</div>
</div>
)}
{contractSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePolicies}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div>
</div>
</div>
)}
<div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
<div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{policy.name}</p>
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div>
<div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
<div className="mt-5 space-y-3">
{pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{rule.name}</p>
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div>
<div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
</div>
</div>
</div>
{accountingSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveAccounting}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
{copy.autoSend}
</label>
</div>
)}
</div>
)
}
@@ -0,0 +1,567 @@
'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: string
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: string
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
interface ProviderAvailability {
amanpay: boolean
paypal: boolean
}
interface PlanFeature {
id: string
plan: Plan
label: string
sortOrder: number
}
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Subscription',
subtitle: 'Manage your plan, payment provider, and subscription invoices.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Abonnement',
subtitle: 'Gérez votre plan, le prestataire de paiement et les factures dabonnement.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الاشتراك',
subtitle: 'إدارة الخطة ومزوّد الدفع وفواتير الاشتراك.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
})
}, [router])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
apiFetch<PlanFeature[]>('/subscriptions/features'),
])
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
if (features) setPlanFeaturesList(features)
}, [])
useEffect(() => {
if (canViewPage !== true) return
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
if (availability.amanpay) setProvider('AMANPAY')
else if (availability.paypal) setProvider('PAYPAL')
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
// currency is always MAD
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [canViewPage, fetchPlanData])
useEffect(() => {
if (canViewPage !== true) return
const handleFocus = () => { fetchPlanData().catch(() => {}) }
const handleVisibility = () => {
if (document.visibilityState === 'visible') fetchPlanData().catch(() => {})
}
window.addEventListener('focus', handleFocus)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('focus', handleFocus)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [canViewPage, fetchPlanData])
if (canViewPage !== true) {
return null
}
async function handleCheckout() {
setPaying(true)
setError(null)
try {
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
currentUrl.hash = ''
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
} catch (err: any) {
setError(err.message)
setPaying(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
async function handleResume() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
return (
<div className="space-y-8">
<div className="rdg-page-heading">
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{/* Trial banner */}
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
{copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p>
</div>
)}
{/* Current plan */}
{subscription && (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-orange-700">
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
)}
</div>
{!subscription.cancelAtPeriodEnd && (
<button
onClick={handleCancel}
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? copy.cancelling : copy.cancelPlan}
</button>
)}
</div>
</div>
)}
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.amanpay && !providerAvailability.paypal ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
</div>
) : null}
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div>
{/* Billing period toggle */}
<div className="flex items-center gap-2">
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
<button
key={p}
onClick={() => setBillingPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-blue-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = planPrices[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
const features = planFeaturesList.filter((f) => f.plan === plan)
return (
<button
key={plan}
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{features.length > 0
? features.map((f) => (
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f.label}
</li>
))
: copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</ul>
</button>
)
})}
</div>
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.amanpay ? (
<button
onClick={() => setProvider('AMANPAY')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'AMANPAY' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🏦 AmanPay
</button>
) : null}
{providerAvailability.paypal ? (
<button
onClick={() => setProvider('PAYPAL')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'PAYPAL' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🔵 PayPal
</button>
) : null}
</div>
</div>
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || (!providerAvailability.amanpay && !providerAvailability.paypal)}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button>
</div>
</div>
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, 'MAD')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
+440
View File
@@ -0,0 +1,440 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { apiFetch } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) {
return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
}
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) {
return language === 'fr' ? "À l'instant" : language === 'ar' ? 'الآن' : 'Just now'
}
if (mins < 60) {
return language === 'fr' ? `il y a ${mins} min` : language === 'ar' ? `منذ ${mins} دقيقة` : `${mins}m ago`
}
const hrs = Math.floor(mins / 60)
if (hrs < 24) {
return language === 'fr' ? `il y a ${hrs}h` : language === 'ar' ? `منذ ${hrs} ساعة` : `${hrs}h ago`
}
const days = Math.floor(hrs / 24)
if (days < 30) {
return language === 'fr' ? `il y a ${days}j` : language === 'ar' ? `منذ ${days} يوم` : `${days}d ago`
}
const locale = language === 'fr' ? 'fr' : language === 'ar' ? 'ar' : 'en'
return new Date(dateStr).toLocaleDateString(locale, { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
const roleLabels: Record<string, Record<'en' | 'fr' | 'ar', string>> = {
OWNER: { en: 'Owner', fr: 'Propriétaire', ar: 'مالك' },
MANAGER: { en: 'Manager', fr: 'Manager', ar: 'مدير' },
AGENT: { en: 'Agent', fr: 'Agent', ar: 'وكيل' },
}
function RoleBadge({ role, language }: { role: string; language: 'en' | 'fr' | 'ar' }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
const label = roleLabels[role]?.[language] ?? (role.charAt(0) + role.slice(1).toLowerCase())
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{label}
</span>
)
}
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border-orange-100 dark:border-orange-900/50">
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span>
)
}
export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: "Gérez vos employés et leurs niveaux d'accès",
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: "Chargement de l'équipe…",
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
apiFetch<{ employee: { id: string } }>('/auth/employee/me')
.then(({ employee }) => {
if (!cancelled) setCurrentEmployeeId(employee.id)
})
.catch(() => {
if (!cancelled) setCurrentEmployeeId(null)
})
return () => { cancelled = true }
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
const handleInvite = async (payload: InvitePayload) => {
await invite(payload)
showToast(`${copy.inviteSent} ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast(copy.roleUpdated)
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast(copy.memberDeactivated)
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast(copy.memberReactivated)
}
const handleRemove = async (id: string) => {
await remove(id)
showToast(copy.memberRemoved)
}
return (
<div className="space-y-6">
<div className="rdg-page-heading mb-6 flex items-start justify-between">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{copy.subtitle}
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
{copy.inviteMember}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: copy.totalMembers, value: stats.total },
{ label: copy.active, value: stats.active },
{ label: copy.pendingInvite, value: stats.pending },
{ label: copy.deactivated, value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allRoles}</option>
<option value="OWNER">{copy.owner}</option>
<option value="MANAGER">{copy.manager}</option>
<option value="AGENT">{copy.agent}</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allStatuses}</option>
<option value="active">{copy.active}</option>
<option value="pending">{copy.pending}</option>
<option value="inactive">{copy.deactivated}</option>
</select>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">{copy.loadingTeam}</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} language={language} />
</td>
<td className="px-4 py-3">
<StatusBadge member={member} language={language} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span>
</td>
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{copy.manage}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<PermissionsMatrix />
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return children
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { JetBrains_Mono } from 'next/font/google'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
import './globals.css'
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business',
icons: {
icon: '/dashboard/icon.svg',
shortcut: '/dashboard/icon.svg',
},
}
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies()
const initialLanguage = resolveInitialLanguage(
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
)
const dir = initialLanguage === 'ar' ? 'rtl' : 'ltr'
return (
<html lang={initialLanguage} dir={dir} className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('dashboard-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}var rootTheme=theme==='light'?'light':'dark';document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(rootTheme);document.documentElement.style.colorScheme=rootTheme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
<body className={`font-sans ${jetbrainsMono.variable}`}>
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
</body>
</html>
)
}
@@ -0,0 +1,26 @@
import * as React from "react";
import Link from 'next/link'
import { PublicPageLayout } from '@/components/public'
export default function AcceptInvitePage() {
return (
<PublicPageLayout>
<main className="flex flex-col items-center justify-center px-4 py-16">
<div className="w-full max-w-md card p-10 text-center">
<div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900">Invitation accepted</h1>
<p className="mt-3 text-sm text-slate-500">
Your invitation has been processed. You can now sign in to access the team dashboard.
</p>
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
Sign in to dashboard
</Link>
</div>
</main>
</PublicPageLayout>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { PublicPageLayout } from '@/components/public'
import { marketplaceUrl } from '@/lib/urls'
type Step = 1 | 2 | 3
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [company, setCompany] = useState({ name: '', slug: '' })
const [brand, setBrand] = useState({
displayName: '',
tagline: '',
primaryColor: '#2563eb',
publicCity: '',
publicCountry: '',
})
const [payments, setPayments] = useState({
amanpayMerchantId: '',
amanpaySecretKey: '',
paypalEmail: '',
isListedOnMarketplace: true,
})
async function handleStep1() {
if (!company.name.trim()) return setError('Company name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me', {
method: 'PATCH',
body: JSON.stringify({ name: company.name }),
})
setStep(2)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep2() {
if (!brand.displayName.trim()) return setError('Display name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
}),
})
setStep(3)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep3() {
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
amanpayMerchantId: payments.amanpayMerchantId || undefined,
amanpaySecretKey: payments.amanpaySecretKey || undefined,
paypalEmail: payments.paypalEmail || undefined,
isListedOnMarketplace: payments.isListedOnMarketplace,
}),
})
router.push('/')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicPageLayout>
<main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex-1 h-1.5 rounded-full transition-colors ${
s <= step ? 'bg-blue-600' : 'bg-slate-200'
}`}
/>
))}
</div>
<div className="card p-8">
{error && (
<div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Your company</h2>
<p className="mt-1 text-sm text-slate-500">Tell us the basics about your rental company.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Company name</label>
<input
className="input-field"
placeholder="Casablanca Car Rentals"
value={company.name}
onChange={(e) => setCompany({ ...company, name: e.target.value })}
/>
</div>
<button onClick={handleStep1} disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Brand &amp; identity</h2>
<p className="mt-1 text-sm text-slate-500">Customize how your company appears to renters.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Display name</label>
<input
className="input-field"
placeholder="Casa Rentals"
value={brand.displayName}
onChange={(e) => setBrand({ ...brand, displayName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tagline <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="The best fleet in the city"
value={brand.tagline}
onChange={(e) => setBrand({ ...brand, tagline: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">City</label>
<input
className="input-field"
placeholder="Casablanca"
value={brand.publicCity}
onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Country</label>
<input
className="input-field"
placeholder="Morocco"
value={brand.publicCountry}
onChange={(e) => setBrand({ ...brand, publicCountry: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Brand color</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-14 rounded border border-slate-200 cursor-pointer"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
<input
className="input-field"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep2} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2>
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="your-merchant-id"
value={payments.amanpayMerchantId}
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
<input
type="password"
className="input-field"
placeholder="your-secret-key"
value={payments.amanpaySecretKey}
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
<input
type="email"
className="input-field"
placeholder="payments@yourcompany.com"
value={payments.paypalEmail}
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/>
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
</label>
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep3} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Finishing…' : 'Go to dashboard'}
</button>
</div>
</div>
)}
</div>
</div>
</main>
</PublicPageLayout>
)
}
@@ -0,0 +1,269 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { PublicPageLayout } from '@/components/public'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const dict = {
en: {
title: 'Create your workspace',
subtitle: 'Enter your email and password to get started.',
email: 'Email',
emailPlaceholder: 'you@company.com',
password: 'Password',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 characters',
languageLabel: 'Preferred language',
create: 'Create workspace',
creating: 'Creating\u2026',
alreadyHave: 'Already have an account?',
signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
successTitle: 'Check your email',
successMessage: 'We sent a verification link to {email}. Click the link to activate your account, then sign in.',
successSignIn: 'Go to sign in',
},
fr: {
title: 'Cr\u00e9ez votre espace',
subtitle: 'Saisissez votre email et mot de passe pour commencer.',
email: 'Email',
emailPlaceholder: 'vous@entreprise.com',
password: 'Mot de passe',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 caract\u00e8res',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026',
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
successTitle: 'V\u00e9rifiez votre email',
successMessage: 'Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.',
successSignIn: 'Aller \u00e0 la connexion',
},
ar: {
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.',
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
emailPlaceholder: 'you@company.com',
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: '\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641',
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
successTitle: '\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
successMessage: '\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
successSignIn: '\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
},
}[preferredLanguage]
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
await apiFetch('/auth/account/start', {
method: 'POST',
body: JSON.stringify({ email, password, preferredLanguage }),
})
setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
setSuccess(true)
} catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
setError(dict.errorEmailTaken)
} else {
setError(dict.errorGeneric)
}
} finally {
setLoading(false)
}
}
if (success) {
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</div>
<div className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/78">
<h1 className="text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.successTitle}
</h1>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
{dict.successMessage.replace('{email}', email)}
</p>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.successSignIn}
</a>
</div>
</div>
</main>
</PublicPageLayout>
)
}
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</a>
</div>
<h1 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.title}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
{error ? (
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email} <span className="text-red-500">*</span>
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
autoFocus
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.password} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">{dict.passwordHint}</p>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.languageLabel} <span className="text-red-500">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setPreferredLanguage(lang)}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
preferredLanguage === lang
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
}`}
>
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{loading ? dict.creating : dict.create}
</button>
</form>
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
{dict.alreadyHave}{' '}
<a
href="/sign-in"
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
>
{dict.signIn}
</a>
</p>
</section>
</div>
</main>
</PublicPageLayout>
)
}
@@ -0,0 +1,10 @@
import { Suspense } from 'react'
import SignUpForm from './SignUpForm'
export default function SignUpPage() {
return (
<Suspense fallback={null}>
<SignUpForm />
</Suspense>
)
}
+130
View File
@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { PublicPageLayout } from '@/components/public'
import { useDashboardI18n } from '@/components/I18nProvider'
export default function VerifyEmailPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const dict = {
en: {
verifying: 'Verifying your email…',
success: 'Email verified! You can now sign in.',
error: 'This verification link is invalid or has expired.',
signIn: 'Go to sign in',
},
fr: {
verifying: 'Vérification de votre email…',
success: 'Email vérifié ! Vous pouvez maintenant vous connecter.',
error: 'Ce lien de vérification est invalide ou a expiré.',
signIn: 'Aller à la connexion',
},
ar: {
verifying: 'جارٍ التحقق من بريدك الإلكتروني…',
success: 'تم التحقق من البريد الإلكتروني! يمكنك الآن تسجيل الدخول.',
error: 'رابط التحقق هذا غير صالح أو منتهي الصلاحية.',
signIn: 'الذهاب إلى تسجيل الدخول',
},
}[language]
useEffect(() => {
if (!token) {
setStatus('error')
return
}
let cancelled = false
async function verify() {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'
const res = await fetch(`${API_BASE}/auth/employee/verify-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (cancelled) return
if (res.ok) {
setStatus('success')
} else {
setStatus('error')
}
} catch {
if (!cancelled) setStatus('error')
}
}
verify()
return () => {
cancelled = true
}
}, [token])
return (
<PublicPageLayout>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</div>
<div className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/78">
{status === 'loading' && (
<>
<div className="mx-auto mb-4 h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
<p className="text-sm text-stone-600 dark:text-stone-300">{dict.verifying}</p>
</>
)}
{status === 'success' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<svg className="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.success}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
{status === 'error' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg className="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.error}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
</div>
</div>
</main>
</PublicPageLayout>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import {
calendarGridDays,
daysBetween,
eventsForCalendarDay,
isSameDay,
parseDateStr,
selectedBlockRange,
toDateStr,
type CalendarEvent,
} from './VehicleCalendar'
describe('VehicleCalendar helpers', () => {
it('formats and parses local date-only strings without shifting the calendar day', () => {
const parsed = parseDateStr('2026-02-09')
expect(parsed.getFullYear()).toBe(2026)
expect(parsed.getMonth()).toBe(1)
expect(parsed.getDate()).toBe(9)
expect(toDateStr(parsed)).toBe('2026-02-09')
})
it('builds a full week-aligned month grid with leading and trailing blanks', () => {
const grid = calendarGridDays(2026, 2)
expect(grid).toHaveLength(28)
expect(grid[0]?.getDate()).toBe(1)
expect(grid.at(-1)?.getDate()).toBe(28)
const march = calendarGridDays(2026, 3)
expect(march).toHaveLength(35)
expect(march[0]?.getDate()).toBe(1)
expect(march[6]?.getDate()).toBe(7)
expect(march.at(-1)).toBeNull()
})
it('treats reservation end dates as exclusive when mapping events to days', () => {
const events: CalendarEvent[] = [
{ id: 'r1', type: 'RESERVATION', startDate: '2026-06-10T10:00:00.000Z', endDate: '2026-06-13T10:00:00.000Z', status: 'CONFIRMED', label: 'Reservation' },
{ id: 'b1', type: 'BLOCK', startDate: '2026-06-13T00:00:00.000Z', endDate: '2026-06-14T00:00:00.000Z', status: null, label: 'Block' },
]
expect(eventsForCalendarDay(events, parseDateStr('2026-06-10')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-12')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-13')).map((event) => event.id)).toEqual(['b1'])
})
it('normalizes two clicked days into an inclusive visual range with exclusive end date', () => {
expect(selectedBlockRange('2026-06-12', parseDateStr('2026-06-10'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
expect(selectedBlockRange('2026-06-10', parseDateStr('2026-06-12'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
})
it('compares calendar days and calculates day spans independent of clock time', () => {
expect(isSameDay(new Date(2026, 5, 9, 1), new Date(2026, 5, 9, 23))).toBe(true)
expect(isSameDay(new Date(2026, 5, 9), new Date(2026, 5, 10))).toBe(false)
expect(daysBetween(parseDateStr('2026-06-09'), parseDateStr('2026-06-12'))).toBe(3)
})
})
@@ -0,0 +1,432 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
export interface CalendarEvent {
id: string
type: EventType
startDate: string
endDate: string
status: string | null
label: string
}
interface Props {
vehicleId: string
}
const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string; dot: string }> = {
RESERVATION: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', dot: 'bg-blue-500' },
MAINTENANCE: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', dot: 'bg-orange-500' },
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
}
export function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
export function parseDateStr(s: string): Date {
const [y, m, day] = s.split('-').map(Number)
return new Date(y, m - 1, day)
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
export function daysBetween(start: Date, end: Date): number {
return Math.round((end.getTime() - start.getTime()) / 86400000)
}
export function calendarGridDays(year: number, month: number): (Date | null)[] {
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay()
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
return gridDays
}
export function eventsForCalendarDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
const dayStr = toDateStr(day)
return events.filter((e) => {
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return start <= dayStr && dayStr < end
})
}
export function selectedBlockRange(selectStart: string, clickedDay: Date): { startDate: string; endDate: string } {
const clicked = toDateStr(clickedDay)
const start = selectStart < clicked ? selectStart : clicked
const end = selectStart < clicked ? clicked : selectStart
return {
startDate: start,
endDate: toDateStr(new Date(parseDateStr(end).getTime() + 86400000)),
}
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Block-creation modal
const [showModal, setShowModal] = useState(false)
const [blockStart, setBlockStart] = useState('')
const [blockEnd, setBlockEnd] = useState('')
const [blockReason, setBlockReason] = useState('')
const [blockType, setBlockType] = useState<'MANUAL' | 'MAINTENANCE'>('MANUAL')
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Day-click selection for quick create
const [selectStart, setSelectStart] = useState<string | null>(null)
const fetchCalendar = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<CalendarEvent[]>(
`/vehicles/${vehicleId}/calendar?year=${year}&month=${month}`
)
setEvents(data)
} catch (e: any) {
setError(e.message ?? c.failedLoad)
} finally {
setLoading(false)
}
}, [vehicleId, year, month, c])
useEffect(() => { fetchCalendar() }, [fetchCalendar])
const prevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
// Build calendar grid (6 rows × 7 cols)
const gridDays = calendarGridDays(year, month)
// Map events to date strings for fast lookup
const eventsOnDay = (day: Date): CalendarEvent[] => eventsForCalendarDay(events, day)
const isToday = (day: Date) => isSameDay(day, today)
const handleDayClick = (day: Date) => {
const str = toDateStr(day)
if (!selectStart) {
setSelectStart(str)
} else {
const range = selectedBlockRange(selectStart, day)
setBlockStart(range.startDate)
setBlockEnd(range.endDate)
setSelectStart(null)
setShowModal(true)
}
}
const openModal = () => {
setBlockStart('')
setBlockEnd('')
setBlockReason('')
setBlockType('MANUAL')
setSaveError(null)
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setSaveError(null)
setSelectStart(null)
}
const handleSaveBlock = async () => {
if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
setSaving(true)
setSaveError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks`, {
method: 'POST',
body: JSON.stringify({ startDate: blockStart, endDate: blockEnd, reason: blockReason || undefined, type: blockType }),
})
setShowModal(false)
fetchCalendar()
} catch (e: any) {
setSaveError(e.message ?? c.failedCreateBlock)
} finally {
setSaving(false)
}
}
const handleDeleteBlock = async (id: string) => {
if (!confirm(c.removeBlockConfirm)) return
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar()
} catch (e: any) {
alert(e.message ?? c.failedDeleteBlock)
}
}
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
const blockEvents = events.filter((e) => e.type !== 'RESERVATION')
return (
<div className="card p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-slate-600" />
<h3 className="text-base font-semibold text-slate-900">{c.heading}</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium text-slate-700 min-w-[130px] text-center">{monthLabel}</span>
<button onClick={nextMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
<Plus className="w-3.5 h-3.5" /> {c.blockDates}
</button>
</div>
</div>
{selectStart && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
{c.clickSecondDay(selectStart!)}
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</button>
</div>
)}
{/* Legend */}
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-orange-500 inline-block" />{c.legendMaintenance}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
{/* Calendar grid */}
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{c.dayHeaders.map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-slate-100 rounded-xl overflow-hidden border border-slate-100">
{gridDays.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="bg-white min-h-[72px]" />
const dayEvents = eventsOnDay(day)
const isSelected = selectStart === toDateStr(day)
const isPast = day < today && !isToday(day)
return (
<div
key={toDateStr(day)}
onClick={() => handleDayClick(day)}
className={[
'bg-white min-h-[72px] p-1.5 cursor-pointer transition-colors',
isToday(day) ? 'ring-2 ring-inset ring-blue-500' : '',
isSelected ? 'bg-blue-50' : 'hover:bg-slate-50',
isPast ? 'opacity-60' : '',
].filter(Boolean).join(' ')}
>
<div className={[
'text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full',
isToday(day) ? 'bg-blue-500 text-white' : 'text-slate-700',
].join(' ')}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 2).map((ev) => {
const s = EVENT_STYLES[ev.type]
return (
<div key={ev.id} className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${s.bg} ${s.text}`}>
{ev.label}
</div>
)
})}
{dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Events list for this month */}
{(reservationEvents.length > 0 || blockEvents.length > 0) && (
<div className="space-y-3 pt-2">
{reservationEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
<div className="space-y-1.5">
{reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${s.dot}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full bg-white/60 ${s.text}`}>{e.status}</span>
</div>
)
})}
</div>
</div>
)}
{blockEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
<div className="space-y-1.5">
{blockEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
const Icon = e.type === 'MAINTENANCE' ? Wrench : Ban
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<Icon className={`w-3.5 h-3.5 ${s.text}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<button
onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title={c.removeBlock}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Add Block Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-blue-950/40 backdrop-blur-sm" onClick={closeModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</h4>
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</label>
<div className="flex gap-2">
<button
onClick={() => setBlockType('MANUAL')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Ban className="w-3.5 h-3.5" /> {c.manualBlock}
</button>
<button
onClick={() => setBlockType('MAINTENANCE')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-orange-50 border-orange-300 text-orange-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
<input
type="date"
className="input-field"
value={blockStart}
onChange={(e) => setBlockStart(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
<input
type="date"
className="input-field"
value={blockEnd}
min={blockStart}
onChange={(e) => setBlockEnd(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.optional}</span></label>
<input
type="text"
className="input-field"
placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
/>
</div>
</div>
{saveError && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{saveError}</div>
)}
<div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? c.saving : c.saveBlock}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildConfigForm,
buildRuleForm,
centsToInput,
inputToCents,
numericInputToInt,
toDateInput,
type PricingConfiguration,
type PricingRule,
} from './VehiclePricingManager'
const configuration: PricingConfiguration = {
id: 'cfg_1',
vehicleId: 'veh_1',
pricingMode: 'AUTOMATIC',
baseDailyRate: 50000,
weeklyRate: 280000,
weekendRate: 65000,
holidayRate: null,
monthlyRate: 950000,
longTermDailyRate: null,
minimumDailyRate: 40000,
maximumDailyRate: 80000,
maxDailyPriceMovementPct: 12,
automaticPricingEnabled: true,
approvalRequired: false,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
const rule: PricingRule = {
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-08-31T00:00:00.000Z',
dailyRate: 70000,
weeklyRate: null,
weekendRate: 90000,
monthlyRate: null,
minDailyRate: 60000,
maxDailyRate: 110000,
automaticAdjustmentPct: 15,
priceAdjustment: 5000,
isActive: false,
sortOrder: 4,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
describe('VehiclePricingManager helpers', () => {
it('converts cents to display input strings and empty nullable values', () => {
expect(centsToInput(123456)).toBe('1234.56')
expect(centsToInput(0)).toBe('0.00')
expect(centsToInput(null)).toBe('')
expect(centsToInput(undefined)).toBe('')
})
it('converts currency inputs to cents while rejecting blank or invalid values', () => {
expect(inputToCents('499.99')).toBe(49999)
expect(inputToCents(' 50 ')).toBe(5000)
expect(inputToCents('')).toBeNull()
expect(inputToCents('abc')).toBeNull()
})
it('parses integer-only fields without accepting blank values', () => {
expect(numericInputToInt('12')).toBe(12)
expect(numericInputToInt('12.8')).toBe(12)
expect(numericInputToInt('')).toBeNull()
expect(numericInputToInt('nope')).toBeNull()
})
it('builds a stable editable configuration form from API pricing data', () => {
expect(buildConfigForm(configuration)).toEqual({
pricingMode: 'AUTOMATIC',
baseDailyRate: '500.00',
weeklyRate: '2800.00',
weekendRate: '650.00',
holidayRate: '',
monthlyRate: '9500.00',
longTermDailyRate: '',
minimumDailyRate: '400.00',
maximumDailyRate: '800.00',
maxDailyPriceMovementPct: '12',
automaticPricingEnabled: true,
approvalRequired: false,
})
})
it('builds an editable rule form from an existing pricing rule', () => {
expect(buildRuleForm(rule)).toEqual({
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01',
endDate: '2026-08-31',
dailyRate: '700.00',
weeklyRate: '',
weekendRate: '900.00',
monthlyRate: '',
minDailyRate: '600.00',
maxDailyRate: '1100.00',
automaticAdjustmentPct: '15',
priceAdjustment: '50.00',
isActive: false,
sortOrder: '4',
})
})
it('defaults new rules to today through six days later', () => {
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
expect(buildRuleForm()).toMatchObject({
name: '',
ruleType: 'DATE_RANGE',
startDate: '2026-06-09',
endDate: '2026-06-15',
isActive: true,
sortOrder: '0',
})
vi.useRealTimers()
})
it('trims ISO timestamps for date input controls', () => {
expect(toDateInput('2026-10-04T22:15:00.000Z')).toBe('2026-10-04')
})
})
@@ -0,0 +1,848 @@
'use client'
import { useEffect, useState } from 'react'
import { CalendarRange, History, PlusCircle, Save, Sparkles, Trash2 } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
export interface PricingConfiguration {
id: string
vehicleId: string
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: number
weeklyRate: number | null
weekendRate: number | null
holidayRate: number | null
monthlyRate: number | null
longTermDailyRate: number | null
minimumDailyRate: number | null
maximumDailyRate: number | null
maxDailyPriceMovementPct: number
automaticPricingEnabled: boolean
approvalRequired: boolean
createdAt: string
updatedAt: string
}
export interface PricingRule {
id: string
name: string
ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
startDate: string
endDate: string
dailyRate: number | null
weeklyRate: number | null
weekendRate: number | null
monthlyRate: number | null
minDailyRate: number | null
maxDailyRate: number | null
automaticAdjustmentPct: number | null
priceAdjustment: number | null
isActive: boolean
sortOrder: number
createdAt: string
updatedAt: string
}
export interface PricingHistoryEntry {
id: string
source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
changedByEmployeeId: string | null
previousDailyRate: number | null
nextDailyRate: number | null
previousWeeklyRate: number | null
nextWeeklyRate: number | null
note: string | null
effectiveFrom: string | null
createdAt: string
}
export interface PricingPreviewItem {
date: string
dailyRate: number
pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
matchedRuleName: string | null
}
export interface PricingResponse {
configuration: PricingConfiguration
rules: PricingRule[]
history: PricingHistoryEntry[]
preview: {
today: PricingPreviewItem
next14Days: PricingPreviewItem[]
}
}
export interface ConfigForm {
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: string
weeklyRate: string
weekendRate: string
holidayRate: string
monthlyRate: string
longTermDailyRate: string
minimumDailyRate: string
maximumDailyRate: string
maxDailyPriceMovementPct: string
automaticPricingEnabled: boolean
approvalRequired: boolean
}
export interface RuleForm {
id?: string
name: string
ruleType: PricingRule['ruleType']
startDate: string
endDate: string
dailyRate: string
weeklyRate: string
weekendRate: string
monthlyRate: string
minDailyRate: string
maxDailyRate: string
automaticAdjustmentPct: string
priceAdjustment: string
isActive: boolean
sortOrder: string
}
export function centsToInput(value: number | null | undefined) {
return value == null ? '' : (value / 100).toFixed(2)
}
export function inputToCents(value: string) {
if (!value.trim()) return null
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) return null
return Math.round(parsed * 100)
}
export function numericInputToInt(value: string) {
if (!value.trim()) return null
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : null
}
export function toDateInput(value: string) {
return value.slice(0, 10)
}
export function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
return {
pricingMode: configuration.pricingMode,
baseDailyRate: centsToInput(configuration.baseDailyRate),
weeklyRate: centsToInput(configuration.weeklyRate),
weekendRate: centsToInput(configuration.weekendRate),
holidayRate: centsToInput(configuration.holidayRate),
monthlyRate: centsToInput(configuration.monthlyRate),
longTermDailyRate: centsToInput(configuration.longTermDailyRate),
minimumDailyRate: centsToInput(configuration.minimumDailyRate),
maximumDailyRate: centsToInput(configuration.maximumDailyRate),
maxDailyPriceMovementPct: String(configuration.maxDailyPriceMovementPct),
automaticPricingEnabled: configuration.automaticPricingEnabled,
approvalRequired: configuration.approvalRequired,
}
}
export function buildRuleForm(rule?: PricingRule): RuleForm {
const today = new Date().toISOString().slice(0, 10)
const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
return {
id: rule?.id,
name: rule?.name ?? '',
ruleType: rule?.ruleType ?? 'DATE_RANGE',
startDate: rule ? toDateInput(rule.startDate) : today,
endDate: rule ? toDateInput(rule.endDate) : nextWeek,
dailyRate: centsToInput(rule?.dailyRate),
weeklyRate: centsToInput(rule?.weeklyRate),
weekendRate: centsToInput(rule?.weekendRate),
monthlyRate: centsToInput(rule?.monthlyRate),
minDailyRate: centsToInput(rule?.minDailyRate),
maxDailyRate: centsToInput(rule?.maxDailyRate),
automaticAdjustmentPct: rule?.automaticAdjustmentPct != null ? String(rule.automaticAdjustmentPct) : '',
priceAdjustment: centsToInput(rule?.priceAdjustment),
isActive: rule?.isActive ?? true,
sortOrder: rule?.sortOrder != null ? String(rule.sortOrder) : '0',
}
}
export default function VehiclePricingManager({ vehicleId }: { vehicleId: string }) {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Pricing management',
subtitle: 'Manage fixed rates, automatic pricing bounds, seasonal rules, and pricing history for this vehicle.',
savePricing: 'Save pricing',
saving: 'Saving…',
loading: 'Loading pricing settings…',
mode: 'Pricing method',
manual: 'Manual fixed pricing',
automatic: 'Automatic dynamic pricing',
baseDailyRate: 'Base daily rate (MAD)',
weeklyRate: 'Weekly rate (MAD)',
weekendRate: 'Weekend rate (MAD)',
holidayRate: 'Holiday rate (MAD)',
monthlyRate: 'Monthly rate (MAD)',
longTermDailyRate: 'Long-term daily rate (MAD)',
minimumDailyRate: 'Minimum daily rate (MAD)',
maximumDailyRate: 'Maximum daily rate (MAD)',
automaticPricingEnabled: 'Automatic pricing enabled',
approvalRequired: 'Approve automatic price changes before they go live',
maxDailyMovement: 'Max daily price movement (%)',
currentPreview: 'Current pricing preview',
next14Days: 'Next 14 days',
matchedRule: 'Matched rule',
noRule: 'No rule',
rules: 'Seasonal and manual rules',
addRule: 'Add rule',
createRule: 'Create rule',
updateRule: 'Save rule',
deleting: 'Deleting…',
deleteRule: 'Delete',
noRules: 'No vehicle-specific pricing rules yet.',
history: 'Pricing history',
noHistory: 'No pricing changes recorded yet.',
active: 'Active',
inactive: 'Inactive',
startDate: 'Start date',
endDate: 'End date',
name: 'Rule name',
ruleType: 'Rule type',
adjustmentPct: 'Auto adjustment (%)',
priceAdjustment: 'Flat adjustment (MAD)',
sortOrder: 'Priority',
saveErrorBase: 'Base daily rate is required.',
saveErrorBounds: 'Minimum daily rate cannot be greater than maximum daily rate.',
saveErrorRule: 'Rules need a name plus at least one rate, bound, or adjustment.',
configSaved: 'Pricing settings saved.',
ruleSaved: 'Pricing rule saved.',
ruleCreated: 'Pricing rule created.',
ruleDeleted: 'Pricing rule deleted.',
previewLabels: {
MANUAL_BASE: 'Base manual price',
AUTOMATIC_BASE: 'Automatic base price',
WEEKEND_RATE: 'Weekend price',
RULE_RATE: 'Manual rule',
AUTOMATIC_RULE: 'Automatic rule',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'Date range',
SEASONAL: 'Season',
HOLIDAY: 'Holiday',
WEEKEND: 'Weekend',
SPECIAL_EVENT: 'Special event',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'Configuration updated',
RULE_CREATED: 'Rule created',
RULE_UPDATED: 'Rule updated',
RULE_DELETED: 'Rule deleted',
MANUAL_OVERRIDE: 'Manual override',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'Use direct rates for fixed pricing or adjustments and bounds to guide automatic pricing.',
},
fr: {
title: 'Gestion des tarifs',
subtitle: 'Gérez les tarifs fixes, les limites automatiques, les saisons et lhistorique des prix de ce véhicule.',
savePricing: 'Enregistrer les tarifs',
saving: 'Enregistrement…',
loading: 'Chargement des paramètres tarifaires…',
mode: 'Méthode tarifaire',
manual: 'Tarification fixe manuelle',
automatic: 'Tarification dynamique automatique',
baseDailyRate: 'Tarif journalier de base (MAD)',
weeklyRate: 'Tarif hebdomadaire (MAD)',
weekendRate: 'Tarif week-end (MAD)',
holidayRate: 'Tarif jours fériés (MAD)',
monthlyRate: 'Tarif mensuel (MAD)',
longTermDailyRate: 'Tarif long séjour / jour (MAD)',
minimumDailyRate: 'Tarif journalier minimum (MAD)',
maximumDailyRate: 'Tarif journalier maximum (MAD)',
automaticPricingEnabled: 'Tarification automatique activée',
approvalRequired: 'Valider les changements automatiques avant publication',
maxDailyMovement: 'Variation maximale par jour (%)',
currentPreview: 'Aperçu des tarifs',
next14Days: '14 prochains jours',
matchedRule: 'Règle appliquée',
noRule: 'Aucune règle',
rules: 'Règles saisonnières et manuelles',
addRule: 'Ajouter une règle',
createRule: 'Créer la règle',
updateRule: 'Enregistrer la règle',
deleting: 'Suppression…',
deleteRule: 'Supprimer',
noRules: 'Aucune règle spécifique au véhicule pour le moment.',
history: 'Historique tarifaire',
noHistory: 'Aucun changement tarifaire enregistré.',
active: 'Active',
inactive: 'Inactive',
startDate: 'Date de début',
endDate: 'Date de fin',
name: 'Nom de la règle',
ruleType: 'Type de règle',
adjustmentPct: 'Ajustement auto (%)',
priceAdjustment: 'Ajustement fixe (MAD)',
sortOrder: 'Priorité',
saveErrorBase: 'Le tarif journalier de base est requis.',
saveErrorBounds: 'Le tarif minimum ne peut pas dépasser le maximum.',
saveErrorRule: 'Les règles nécessitent un nom et au moins un tarif, une limite ou un ajustement.',
configSaved: 'Paramètres tarifaires enregistrés.',
ruleSaved: 'Règle tarifaire enregistrée.',
ruleCreated: 'Règle tarifaire créée.',
ruleDeleted: 'Règle tarifaire supprimée.',
previewLabels: {
MANUAL_BASE: 'Tarif manuel de base',
AUTOMATIC_BASE: 'Base automatique',
WEEKEND_RATE: 'Tarif week-end',
RULE_RATE: 'Règle manuelle',
AUTOMATIC_RULE: 'Règle automatique',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'Plage de dates',
SEASONAL: 'Saison',
HOLIDAY: 'Jour férié',
WEEKEND: 'Week-end',
SPECIAL_EVENT: 'Événement spécial',
MANUAL_OVERRIDE: 'Forçage manuel',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'Configuration mise à jour',
RULE_CREATED: 'Règle créée',
RULE_UPDATED: 'Règle mise à jour',
RULE_DELETED: 'Règle supprimée',
MANUAL_OVERRIDE: 'Forçage manuel',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'Utilisez des tarifs directs pour le fixe ou des ajustements et limites pour guider lautomatique.',
},
ar: {
title: 'إدارة التسعير',
subtitle: 'إدارة الأسعار الثابتة وحدود التسعير التلقائي والقواعد الموسمية وسجل تغييرات السعر لهذه السيارة.',
savePricing: 'حفظ التسعير',
saving: 'جارٍ الحفظ…',
loading: 'جارٍ تحميل إعدادات التسعير…',
mode: 'طريقة التسعير',
manual: 'تسعير ثابت يدوي',
automatic: 'تسعير ديناميكي تلقائي',
baseDailyRate: 'السعر اليومي الأساسي (درهم)',
weeklyRate: 'السعر الأسبوعي (درهم)',
weekendRate: 'سعر عطلة نهاية الأسبوع (درهم)',
holidayRate: 'سعر العطل (درهم)',
monthlyRate: 'السعر الشهري (درهم)',
longTermDailyRate: 'سعر الإيجار الطويل / يوم (درهم)',
minimumDailyRate: 'الحد الأدنى للسعر اليومي (درهم)',
maximumDailyRate: 'الحد الأقصى للسعر اليومي (درهم)',
automaticPricingEnabled: 'تفعيل التسعير التلقائي',
approvalRequired: 'اعتماد تغييرات السعر التلقائية قبل نشرها',
maxDailyMovement: 'أقصى تغيير يومي (%)',
currentPreview: 'معاينة التسعير الحالية',
next14Days: 'الأيام الـ 14 القادمة',
matchedRule: 'القاعدة المطبقة',
noRule: 'لا توجد قاعدة',
rules: 'القواعد الموسمية واليدوية',
addRule: 'إضافة قاعدة',
createRule: 'إنشاء القاعدة',
updateRule: 'حفظ القاعدة',
deleting: 'جارٍ الحذف…',
deleteRule: 'حذف',
noRules: 'لا توجد قواعد تسعير خاصة بهذه السيارة بعد.',
history: 'سجل التسعير',
noHistory: 'لا توجد تغييرات تسعير مسجلة بعد.',
active: 'نشطة',
inactive: 'غير نشطة',
startDate: 'تاريخ البدء',
endDate: 'تاريخ الانتهاء',
name: 'اسم القاعدة',
ruleType: 'نوع القاعدة',
adjustmentPct: 'تعديل تلقائي (%)',
priceAdjustment: 'تعديل ثابت (درهم)',
sortOrder: 'الأولوية',
saveErrorBase: 'السعر اليومي الأساسي مطلوب.',
saveErrorBounds: 'لا يمكن أن يكون الحد الأدنى أعلى من الحد الأقصى.',
saveErrorRule: 'تحتاج القواعد إلى اسم وإلى سعر أو حد أو تعديل واحد على الأقل.',
configSaved: 'تم حفظ إعدادات التسعير.',
ruleSaved: 'تم حفظ قاعدة التسعير.',
ruleCreated: 'تم إنشاء قاعدة التسعير.',
ruleDeleted: 'تم حذف قاعدة التسعير.',
previewLabels: {
MANUAL_BASE: 'السعر اليدوي الأساسي',
AUTOMATIC_BASE: 'السعر الأساسي التلقائي',
WEEKEND_RATE: 'سعر عطلة نهاية الأسبوع',
RULE_RATE: 'قاعدة يدوية',
AUTOMATIC_RULE: 'قاعدة تلقائية',
} as Record<PricingPreviewItem['pricingType'], string>,
ruleTypeLabels: {
DATE_RANGE: 'نطاق تاريخ',
SEASONAL: 'موسم',
HOLIDAY: 'عطلة',
WEEKEND: 'نهاية أسبوع',
SPECIAL_EVENT: 'مناسبة خاصة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingRule['ruleType'], string>,
historyLabels: {
CONFIG_UPDATE: 'تم تحديث الإعدادات',
RULE_CREATED: 'تم إنشاء قاعدة',
RULE_UPDATED: 'تم تحديث قاعدة',
RULE_DELETED: 'تم حذف قاعدة',
MANUAL_OVERRIDE: 'تجاوز يدوي',
} as Record<PricingHistoryEntry['source'], string>,
ruleHint: 'استخدم أسعاراً مباشرة للتسعير الثابت أو تعديلات وحدوداً لتوجيه التسعير التلقائي.',
},
}[language]
const [loading, setLoading] = useState(true)
const [savingConfig, setSavingConfig] = useState(false)
const [savingRuleId, setSavingRuleId] = useState<string | null>(null)
const [deletingRuleId, setDeletingRuleId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [configForm, setConfigForm] = useState<ConfigForm | null>(null)
const [rules, setRules] = useState<RuleForm[]>([])
const [newRule, setNewRule] = useState<RuleForm>(buildRuleForm())
const [history, setHistory] = useState<PricingHistoryEntry[]>([])
const [preview, setPreview] = useState<PricingResponse['preview'] | null>(null)
function applyResponse(data: PricingResponse) {
setConfigForm(buildConfigForm(data.configuration))
setRules(data.rules.map((rule) => buildRuleForm(rule)))
setHistory(data.history)
setPreview(data.preview)
}
async function load() {
setLoading(true)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing`)
applyResponse(data)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load vehicle pricing')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [vehicleId])
async function saveConfiguration() {
if (!configForm) return
const baseDailyRate = inputToCents(configForm.baseDailyRate)
const minimumDailyRate = inputToCents(configForm.minimumDailyRate)
const maximumDailyRate = inputToCents(configForm.maximumDailyRate)
if (baseDailyRate == null) {
setError(copy.saveErrorBase)
return
}
if (minimumDailyRate != null && maximumDailyRate != null && minimumDailyRate > maximumDailyRate) {
setError(copy.saveErrorBounds)
return
}
setSavingConfig(true)
setError(null)
setSuccess(null)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing`, {
method: 'PUT',
body: JSON.stringify({
pricingMode: configForm.pricingMode,
baseDailyRate,
weeklyRate: inputToCents(configForm.weeklyRate),
weekendRate: inputToCents(configForm.weekendRate),
holidayRate: inputToCents(configForm.holidayRate),
monthlyRate: inputToCents(configForm.monthlyRate),
longTermDailyRate: inputToCents(configForm.longTermDailyRate),
minimumDailyRate,
maximumDailyRate,
maxDailyPriceMovementPct: numericInputToInt(configForm.maxDailyPriceMovementPct) ?? 10,
automaticPricingEnabled: configForm.automaticPricingEnabled,
approvalRequired: configForm.approvalRequired,
note: copy.configSaved,
}),
})
applyResponse(data)
setSuccess(copy.configSaved)
} catch (err: any) {
setError(err.message ?? copy.saveErrorBase)
} finally {
setSavingConfig(false)
}
}
async function saveRule(rule: RuleForm, create = false) {
const hasPricingEffect = [
inputToCents(rule.dailyRate),
inputToCents(rule.weeklyRate),
inputToCents(rule.weekendRate),
inputToCents(rule.monthlyRate),
inputToCents(rule.minDailyRate),
inputToCents(rule.maxDailyRate),
numericInputToInt(rule.automaticAdjustmentPct),
inputToCents(rule.priceAdjustment),
].some((value) => value != null)
if (!rule.name.trim() || !hasPricingEffect) {
setError(copy.saveErrorRule)
return
}
const payload = {
name: rule.name.trim(),
ruleType: rule.ruleType,
startDate: rule.startDate,
endDate: rule.endDate,
dailyRate: inputToCents(rule.dailyRate),
weeklyRate: inputToCents(rule.weeklyRate),
weekendRate: inputToCents(rule.weekendRate),
monthlyRate: inputToCents(rule.monthlyRate),
minDailyRate: inputToCents(rule.minDailyRate),
maxDailyRate: inputToCents(rule.maxDailyRate),
automaticAdjustmentPct: numericInputToInt(rule.automaticAdjustmentPct),
priceAdjustment: inputToCents(rule.priceAdjustment),
isActive: rule.isActive,
sortOrder: numericInputToInt(rule.sortOrder) ?? 0,
}
setSavingRuleId(rule.id ?? 'new')
setError(null)
setSuccess(null)
try {
const data = create
? await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules`, { method: 'POST', body: JSON.stringify(payload) })
: await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules/${rule.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
applyResponse(data)
if (create) {
setNewRule(buildRuleForm())
setSuccess(copy.ruleCreated)
} else {
setSuccess(copy.ruleSaved)
}
} catch (err: any) {
setError(err.message ?? copy.saveErrorRule)
} finally {
setSavingRuleId(null)
}
}
async function deleteRule(ruleId: string) {
setDeletingRuleId(ruleId)
setError(null)
setSuccess(null)
try {
const data = await apiFetch<PricingResponse>(`/vehicles/${vehicleId}/pricing/rules/${ruleId}`, { method: 'DELETE' })
applyResponse(data)
setSuccess(copy.ruleDeleted)
} catch (err: any) {
setError(err.message ?? copy.ruleDeleted)
} finally {
setDeletingRuleId(null)
}
}
if (loading || !configForm || !preview) {
return <div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
}
return (
<div className="space-y-6">
<div className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.title}</h3>
<p className="mt-1 max-w-3xl text-sm text-slate-500">{copy.subtitle}</p>
</div>
<button onClick={saveConfiguration} disabled={savingConfig} className="btn-primary">
<Save className="h-4 w-4" />
{savingConfig ? copy.saving : copy.savePricing}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{success && <div className="mt-4 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{success}</div>}
<div className="mt-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-2">
<div className="md:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.mode}</label>
<select className="input-field" value={configForm.pricingMode} onChange={(event) => setConfigForm((current) => current ? { ...current, pricingMode: event.target.value as ConfigForm['pricingMode'] } : current)}>
<option value="MANUAL">{copy.manual}</option>
<option value="AUTOMATIC">{copy.automatic}</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.baseDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, baseDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weeklyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weeklyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.weekendRate} onChange={(event) => setConfigForm((current) => current ? { ...current, weekendRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.holidayRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.holidayRate} onChange={(event) => setConfigForm((current) => current ? { ...current, holidayRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.monthlyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, monthlyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.longTermDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.longTermDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, longTermDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.minimumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, minimumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={configForm.maximumDailyRate} onChange={(event) => setConfigForm((current) => current ? { ...current, maximumDailyRate: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maxDailyMovement}</label>
<input className="input-field" type="number" min="0" max="100" step="1" value={configForm.maxDailyPriceMovementPct} onChange={(event) => setConfigForm((current) => current ? { ...current, maxDailyPriceMovementPct: event.target.value } : current)} />
</div>
<div className="md:col-span-2 flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.automaticPricingEnabled} onChange={(event) => setConfigForm((current) => current ? { ...current, automaticPricingEnabled: event.target.checked } : current)} />
{copy.automaticPricingEnabled}
</label>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={configForm.approvalRequired} onChange={(event) => setConfigForm((current) => current ? { ...current, approvalRequired: event.target.checked } : current)} />
{copy.approvalRequired}
</label>
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.currentPreview}</h4>
</div>
<div className="mt-4 rounded-2xl bg-slate-50 p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{preview.today.date}</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{formatCurrency(preview.today.dailyRate, 'MAD')}</p>
<p className="mt-1 text-sm text-slate-500">{copy.previewLabels[preview.today.pricingType]}</p>
<p className="mt-2 text-xs text-slate-400">{copy.matchedRule}: {preview.today.matchedRuleName ?? copy.noRule}</p>
</div>
<div className="mt-4">
<div className="mb-3 flex items-center gap-2">
<CalendarRange className="h-4 w-4 text-slate-400" />
<p className="text-sm font-semibold text-slate-900">{copy.next14Days}</p>
</div>
<div className="space-y-2">
{preview.next14Days.map((item) => (
<div key={item.date} className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2 text-sm">
<div>
<p className="font-medium text-slate-900">{item.date}</p>
<p className="text-xs text-slate-500">{copy.previewLabels[item.pricingType]}</p>
</div>
<div className="text-right">
<p className="font-semibold text-slate-900">{formatCurrency(item.dailyRate, 'MAD')}</p>
<p className="text-xs text-slate-400">{item.matchedRuleName ?? copy.noRule}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.rules}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.ruleHint}</p>
<div className="mt-5 space-y-4">
{rules.map((rule, index) => (
<div key={rule.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={rule.name} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={rule.ruleType} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, ruleType: event.target.value as RuleForm['ruleType'] } : entry))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={rule.startDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, startDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={rule.endDate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, endDate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.dailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, dailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weeklyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weeklyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.weekendRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, weekendRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.monthlyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, monthlyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.minDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, minDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={rule.maxDailyRate} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, maxDailyRate: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={rule.automaticAdjustmentPct} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, automaticAdjustmentPct: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={rule.priceAdjustment} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, priceAdjustment: event.target.value } : entry))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={rule.sortOrder} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sortOrder: event.target.value } : entry))} />
</div>
<div className="flex items-end">
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={rule.isActive} onChange={(event) => setRules((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, isActive: event.target.checked } : entry))} />
{rule.isActive ? copy.active : copy.inactive}
</label>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2">
<button onClick={() => deleteRule(rule.id!)} disabled={deletingRuleId === rule.id} className="btn-secondary text-red-600">
<Trash2 className="h-4 w-4" />
{deletingRuleId === rule.id ? copy.deleting : copy.deleteRule}
</button>
<button onClick={() => saveRule(rule)} disabled={savingRuleId === rule.id} className="btn-primary">
<Save className="h-4 w-4" />
{savingRuleId === rule.id ? copy.saving : copy.updateRule}
</button>
</div>
</div>
))}
{rules.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-6 rounded-2xl border border-blue-100 bg-blue-50/60 p-4">
<div className="mb-4 flex items-center gap-2">
<PlusCircle className="h-4 w-4 text-blue-600" />
<h4 className="text-sm font-semibold text-slate-900">{copy.addRule}</h4>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.name}</label>
<input className="input-field" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.ruleType}</label>
<select className="input-field" value={newRule.ruleType} onChange={(event) => setNewRule((current) => ({ ...current, ruleType: event.target.value as RuleForm['ruleType'] }))}>
{Object.entries(copy.ruleTypeLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startDate}</label>
<input className="input-field" type="date" value={newRule.startDate} onChange={(event) => setNewRule((current) => ({ ...current, startDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endDate}</label>
<input className="input-field" type="date" value={newRule.endDate} onChange={(event) => setNewRule((current) => ({ ...current, endDate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.baseDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.dailyRate} onChange={(event) => setNewRule((current) => ({ ...current, dailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weeklyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weeklyRate} onChange={(event) => setNewRule((current) => ({ ...current, weeklyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.weekendRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.weekendRate} onChange={(event) => setNewRule((current) => ({ ...current, weekendRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.monthlyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.monthlyRate} onChange={(event) => setNewRule((current) => ({ ...current, monthlyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.minimumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.minDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, minDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.maximumDailyRate}</label>
<input className="input-field" type="number" min="0" step="0.01" value={newRule.maxDailyRate} onChange={(event) => setNewRule((current) => ({ ...current, maxDailyRate: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.adjustmentPct}</label>
<input className="input-field" type="number" step="1" value={newRule.automaticAdjustmentPct} onChange={(event) => setNewRule((current) => ({ ...current, automaticAdjustmentPct: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.priceAdjustment}</label>
<input className="input-field" type="number" step="0.01" value={newRule.priceAdjustment} onChange={(event) => setNewRule((current) => ({ ...current, priceAdjustment: event.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.sortOrder}</label>
<input className="input-field" type="number" min="0" step="1" value={newRule.sortOrder} onChange={(event) => setNewRule((current) => ({ ...current, sortOrder: event.target.value }))} />
</div>
</div>
<div className="mt-4 flex justify-end">
<button onClick={() => saveRule(newRule, true)} disabled={savingRuleId === 'new'} className="btn-primary">
<PlusCircle className="h-4 w-4" />
{savingRuleId === 'new' ? copy.saving : copy.createRule}
</button>
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-slate-400" />
<h3 className="text-base font-semibold text-slate-900">{copy.history}</h3>
</div>
<div className="mt-5 space-y-3">
{history.map((entry) => (
<div key={entry.id} className="rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{copy.historyLabels[entry.source]}</p>
<p className="text-slate-500">{new Date(entry.createdAt).toLocaleString()}</p>
</div>
<div className="text-right">
{entry.nextDailyRate != null && <p className="font-semibold text-slate-900">{formatCurrency(entry.nextDailyRate, 'MAD')}</p>}
{entry.previousDailyRate != null && entry.nextDailyRate != null && (
<p className="text-xs text-slate-400">{formatCurrency(entry.previousDailyRate, 'MAD')} {formatCurrency(entry.nextDailyRate, 'MAD')}</p>
)}
</div>
</div>
{entry.note && <p className="mt-2 text-sm text-slate-600">{entry.note}</p>}
</div>
))}
{history.length === 0 && <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400">{copy.noHistory}</div>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => {
it('normalizes dashboard-prefixed menu routes before checking access', () => {
const routes = flattenInternalRoutes([
{
id: 'dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard',
children: [],
},
{
id: 'fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard/fleet',
children: [],
},
{
id: 'external',
itemType: 'EXTERNAL_LINK',
routeOrUrl: '/dashboard/dashboard',
children: [],
},
])
expect(routes).toEqual(['/', '/fleet'])
expect(isAllowedRoute('/', routes)).toBe(true)
expect(isAllowedRoute('/fleet/123', routes)).toBe(true)
})
it('does not redirect forever when an authenticated employee has no visible menu routes', () => {
expect(resolveAccessRedirect('/', [])).toBeNull()
expect(resolveAccessRedirect('/fleet', [])).toBeNull()
})
it('redirects disallowed routes to the first visible internal route', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
})
})
@@ -0,0 +1,101 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
type GeneratedMenuItem = {
id: string
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
routeOrUrl: string | null
children: GeneratedMenuItem[]
}
type EmployeeMenuResponse = {
items: GeneratedMenuItem[]
}
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
const routes: string[] = []
const walk = (entry: GeneratedMenuItem) => {
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
routes.push(toDashboardAppPath(entry.routeOrUrl))
}
entry.children.forEach(walk)
}
items.forEach(walk)
return Array.from(new Set(routes))
}
export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
return allowedRoutes.some((route) => {
if (route === '/') return currentPath === '/'
return currentPath === route || currentPath.startsWith(`${route}/`)
})
}
export function resolveAccessRedirect(currentPath: string, allowedRoutes: string[]) {
if (allowedRoutes.length === 0) return null
if (isAllowedRoute(currentPath, allowedRoutes)) return null
const fallbackRoute = allowedRoutes[0] ?? '/'
return fallbackRoute === currentPath ? null : fallbackRoute
}
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
async function enforceAccess() {
try {
setReady(false)
const currentPath = toDashboardAppPath(pathname)
const menu = await apiFetch<EmployeeMenuResponse>('/auth/employee/menu')
if (cancelled) return
const allowedRoutes = flattenInternalRoutes(menu.items)
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
if (redirectPath) {
router.replace(redirectPath)
return
}
setReady(true)
} catch (error: any) {
if (cancelled) return
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
router.replace('/')
return
}
// Do not deadlock the dashboard on transient network failures.
setReady(true)
}
}
enforceAccess()
return () => {
cancelled = true
}
}, [pathname, router])
if (!ready) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label="Checking access" />
</div>
)
}
return <>{children}</>
}
@@ -0,0 +1,4 @@
'use client'
// Compatibility export. New pages should import PublicFooter from '@/components/public'.
export { default } from '@/components/public/PublicFooter'
@@ -0,0 +1,4 @@
'use client'
// Compatibility export. New pages should import PublicNavbar from '@/components/public'.
export { default } from '@/components/public/PublicNavbar'
@@ -0,0 +1,32 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/components/public/PublicNavbar', () => ({ default: function MockPublicNavbar() { return React.createElement('mock-navbar') } }))
vi.mock('@/components/public/PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicShell from './PublicShell'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
if (typeof type === 'string') return type
return type.displayName ?? type.name ?? 'anonymous'
})
}
describe('dashboard public page compatibility shell', () => {
it('wraps public pages with navbar, content, and footer by default', () => {
const shell = PublicShell({ children: React.createElement('main', { id: 'content' }) })
expect(isValidElement(shell)).toBe(true)
expect(childTypes(shell)).toEqual(['MockPublicNavbar', 'div', 'MockPublicFooter'])
})
it('omits chrome when embedded so partner surfaces do not get nested navigation', () => {
const shell = PublicShell({ embedded: true, children: React.createElement('main', { id: 'content' }) })
expect(childTypes(shell)).toEqual(['div'])
const contentWrapper = React.Children.toArray(shell.props.children).find(isValidElement) as React.ReactElement
expect(contentWrapper.props.children).toEqual(React.createElement('main', { id: 'content' }))
})
})
@@ -0,0 +1,5 @@
'use client'
// Compatibility export. New pages should import PublicPageLayout from '@/components/public'.
export { default } from '@/components/public/PublicPageLayout'
export type { PublicPageLayoutProps } from '@/components/public/PublicPageLayout'
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { hasRenderableMenuItems } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false)
})
it('ignores structural-only menu responses without clickable entries', () => {
expect(hasRenderableMenuItems([
{
id: 'section',
systemKey: null,
label: 'Workspace',
itemType: 'SECTION_LABEL',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [],
},
{
id: 'divider',
systemKey: null,
label: '',
itemType: 'DIVIDER',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 2,
children: [],
},
])).toBe(false)
})
it('detects nested clickable menu entries', () => {
expect(hasRenderableMenuItems([
{
id: 'parent',
systemKey: null,
label: 'Operations',
itemType: 'PARENT_MENU',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [
{
id: 'fleet',
systemKey: 'fleet',
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/fleet',
icon: 'Car',
parentId: 'parent',
openInNewTab: false,
displayOrder: 1,
children: [],
},
],
},
])).toBe(true)
})
})
+482
View File
@@ -0,0 +1,482 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import type { ReactNode } from 'react'
import { useEffect, useLayoutEffect, useState } from 'react'
import {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
LogOut,
FileText,
ChevronLeft,
ChevronRight,
Star,
AlertTriangle,
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface CompanySummary {
name: string
brand?: {
displayName?: string | null
logoUrl?: string | null
} | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
interface EmployeeProfile {
email: string
firstName: string
lastName: string
role: string
preferredLanguage?: string
}
interface GeneratedMenuItem {
id: string
systemKey: string | null
label: string
itemType: 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
routeOrUrl: string | null
icon: string | null
parentId: string | null
openInNewTab: boolean
displayOrder: number
children: GeneratedMenuItem[]
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
const role = profile.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
const subtitle = email || role || fallbackSubtitle
const initials = computeInitials(fullName || email || fallbackName)
return { displayName, subtitle, initials }
}
const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
const ICON_MAP = {
LayoutDashboard,
Car,
Calendar,
Globe,
Users,
Tag,
UserPlus,
BarChart2,
CreditCard,
Bell,
Settings,
FileText,
Star,
AlertTriangle,
} as const
const SIDEBAR_BRAND_KEY = 'dashboard_brand'
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
}
export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boolean {
if (!items?.length) return false
return items.some((item) => {
if (item.itemType === 'DIVIDER' || item.itemType === 'SECTION_LABEL') return false
if (item.itemType === 'PARENT_MENU') return hasRenderableMenuItems(item.children)
return Boolean(item.routeOrUrl)
})
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function Sidebar() {
const { dict, language, setLanguage } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
useLayoutEffect(() => {
try {
const cached = window.localStorage.getItem(SIDEBAR_BRAND_KEY)
if (!cached) return
const parsed = JSON.parse(cached) as Partial<BrandSettings>
if (typeof parsed.displayName === 'string' && parsed.displayName.trim()) {
setBrand({
displayName: parsed.displayName,
logoUrl: typeof parsed.logoUrl === 'string' ? parsed.logoUrl : null,
})
}
} catch {}
}, [])
useEffect(() => {
let cancelled = false
async function loadBrand() {
try {
const brandData = await apiFetch<BrandSettings | null>('/companies/me/brand')
if (cancelled) return
if (brandData?.displayName?.trim()) {
setBrand(brandData)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(brandData))
return
}
} catch {}
try {
const company = await apiFetch<CompanySummary>('/companies/me')
if (cancelled) return
const resolvedBrand: BrandSettings = {
displayName: company.brand?.displayName?.trim() || company.name,
logoUrl: company.brand?.logoUrl ?? null,
}
setBrand(resolvedBrand)
window.localStorage.setItem(SIDEBAR_BRAND_KEY, JSON.stringify(resolvedBrand))
} catch {}
}
loadBrand()
return () => { cancelled = true }
}, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
if (profile.role) setRole(profile.role)
} catch {}
}
let cancelled = false
Promise.all([
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me'),
apiFetch<{ items: GeneratedMenuItem[] }>('/auth/employee/menu').catch(() => null),
])
.then(([{ employee }, menu]) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (menu?.items) setMenuItems(menu.items)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
// Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to.
const existingPref = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
if (!existingPref) {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
}
}
})
.catch(() => {
if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
}
const isGeneratedItemActive = (item: GeneratedMenuItem) => {
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
const route = toDashboardAppPath(item.routeOrUrl)
if (route === '/') return appPath === '/'
return appPath === route || appPath.startsWith(`${route}/`)
}
const fallbackMenuItems = NAV_ITEMS
.filter((item) => !mounted || hasMinRole(role, item.minRole))
.map((item) => ({
id: item.href,
systemKey: item.key,
label: dict.nav[item.key] ?? item.key,
itemType: 'INTERNAL_PAGE' as const,
routeOrUrl: item.href,
icon: item.icon.name,
parentId: null,
openInNewTab: false,
displayOrder: 0,
children: [],
}))
const useGeneratedMenu = hasRenderableMenuItems(menuItems)
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
const label = item.systemKey ? (dict.nav[item.systemKey] ?? item.label) : item.label
const paddingClass = depth > 0 ? 'pl-6' : ''
const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null
if (item.itemType === 'DIVIDER') {
return <div key={item.id} className="mx-3 my-3 h-px bg-gradient-to-r from-transparent via-blue-400/20 to-transparent" />
}
if (item.itemType === 'SECTION_LABEL') {
return (
<p key={item.id} className="px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
{label}
</p>
)
}
if (item.itemType === 'PARENT_MENU') {
return (
<div key={item.id} className="space-y-1">
<div className={`rdg-nav-item flex items-center gap-3 rounded-lg px-3.5 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-300 ${paddingClass}`}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</div>
<div className="space-y-0.5">{renderGeneratedMenu(item.children, depth + 1)}</div>
</div>
)
}
const active = mounted && isGeneratedItemActive(item)
const className = [
'rdg-nav-item relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
paddingClass,
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
return (
<a
key={item.id}
href={item.routeOrUrl}
target={item.openInNewTab ? '_blank' : '_self'}
rel={item.openInNewTab ? 'noreferrer noopener' : undefined}
className={className}
data-active={active ? 'true' : 'false'}
>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</a>
)
}
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
return (
<Link key={item.id} href={href} className={className} aria-current={active ? 'page' : undefined} data-active={active ? 'true' : 'false'}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label}
</Link>
)
})
}
function signOut() {
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
}
return (
<>
{/* Mobile backdrop */}
{open && (
<div
className="fixed inset-0 z-30 bg-blue-950/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && (
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 flex h-14 w-6 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
)}
<aside
className={[
'rdg-sidebar fixed inset-y-0 z-40 flex w-[var(--rdg-sidebar-size)] flex-col border-e border-blue-200/70 bg-white/90 text-blue-950 shadow-[8px_0_32px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-[transform,background-color,border-color,color,box-shadow] duration-300 dark:border-blue-400/10 dark:bg-[#0a0f1a]/95 dark:text-slate-100 dark:shadow-[8px_0_32px_rgba(0,0,0,0.22)]',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<a
href={marketplaceUrl}
target="_top"
className="rdg-sidebar-brand flex items-center gap-3 border-b border-blue-200/70 px-5 py-4 transition-colors dark:border-blue-400/10"
>
<div className="rdg-brand-mark flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-white" />
)}
</div>
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</a>
<nav className="rdg-sidebar-nav flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = mounted && isActive(item)
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
data-active={active ? 'true' : 'false'}
className={[
'rdg-nav-item relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')}
>
<Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
{dict.nav[item.key]}
{item.key === 'dashboard' ? (
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
Live
</span>
) : null}
{item.key === 'notifications' && active ? (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
) : null}
</Link>
)
})}
</nav>
<div className="rdg-sidebar-footer border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
<div className="rdg-user-card rounded-xl border border-blue-200/70 bg-blue-50/75 p-3 backdrop-blur dark:border-blue-400/10 dark:bg-blue-500/[0.06]">
<div className="flex items-center gap-3">
<div className="rdg-avatar flex h-9 w-9 flex-shrink-0 items-center justify-center text-xs font-semibold">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-blue-950 dark:text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{user.subtitle}</p>
</div>
</div>
</div>
<button
type="button"
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
<div className="mt-3 flex items-center gap-2 border-t border-blue-200/70 pt-3 dark:border-blue-400/10">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 flex h-14 w-5 -translate-y-1/2 items-center justify-center bg-[#0a1128] text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
)
}
+289
View File
@@ -0,0 +1,289 @@
'use client'
import Link from 'next/link'
import { Bell, Search, Settings } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function resolveSocketOrigin(): string | null {
if (typeof window === 'undefined') return null
const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL
if (configuredApiUrl) {
try {
return new URL(configuredApiUrl, window.location.origin).origin
} catch {}
}
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return 'http://localhost:4000'
}
return window.location.origin
}
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const [socketEnabled, setSocketEnabled] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
if (appPath.startsWith('/contracts/')) return dict.titles['/contracts'] ?? dict.titles['/']
if (appPath.startsWith('/reservations/')) return dict.titles['/reservations'] ?? dict.titles['/']
return dict.titles['/']
})()
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
setSocketEnabled(true)
} catch {
setUnreadCount(0)
setSocketEnabled(false)
}
}
useEffect(() => {
refreshUnreadCount()
}, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!socketEnabled) return
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
withCredentials: true,
transports: ['polling', 'websocket'],
})
const handleNotification = () => {
setUnreadCount((prev) => prev + 1)
window.dispatchEvent(new Event('notifications:updated'))
}
socket.on('notification', handleNotification)
const connectTimer = window.setTimeout(() => {
socket.connect()
}, 0)
return () => {
window.clearTimeout(connectTimer)
socket.off('notification', handleNotification)
socket.disconnect()
}
}, [socketEnabled])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { email?: string; firstName?: string; lastName?: string }
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
} catch {}
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/notifications')
}
return (
<header className="rdg-topbar relative z-[70] px-4 sm:px-6">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0 shrink-0">
<p className="rdg-topbar-kicker hidden sm:block">RentalDriveGo</p>
<h1 className="rdg-topbar-title truncate">{title}</h1>
</div>
<div className="rdg-topbar-search relative hidden w-[min(380px,34vw)] md:block">
<Search className="pointer-events-none absolute start-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Search vehicles, bookings, customers..."
className="rdg-search-field h-10 w-full ps-10 pe-4 text-sm outline-none"
/>
</div>
<div className="rdg-system-status hidden xl:inline-flex">
System operational
</div>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowNotifs(!showNotifs)}
className="relative inline-flex h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)]"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 ? (
<span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-orange-500 px-1 text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#0a0f1a]">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
) : null}
</button>
{showNotifs ? (
<div className="card absolute top-full z-[80] mt-2 w-80 p-4 [inset-inline-end:0]">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-blue-950 dark:text-slate-100">{dict.notifications}</p>
<button
type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-orange-700 hover:text-blue-950 dark:text-orange-300 dark:hover:text-white"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-blue-500/10',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-blue-950 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
<Link
href="/settings"
className="hidden h-11 w-11 items-center justify-center rounded-[var(--rdg-radius-sm)] text-[var(--rdg-text-secondary)] transition-colors hover:bg-[var(--rdg-soft-blue)] hover:text-[var(--rdg-action-primary)] sm:inline-flex"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</Link>
<div className="rdg-avatar flex h-9 w-9 items-center justify-center text-xs font-semibold">
{userInitials}
</div>
</div>
</header>
)
}
@@ -0,0 +1,187 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import {
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
type FooterItem = {
label: string
href?: string
}
const appPrivacyHref = {
en: `${marketplaceUrl}/app-privacy-en`,
fr: `${marketplaceUrl}/app-privacy-fr`,
ar: `${marketplaceUrl}/app-privacy-ar`,
} as const
const localeOptions: Array<{ value: DashboardLanguage; label: string; flag: string }> = [
{ value: 'en', label: 'Global (English)', flag: '🇺🇸' },
{ value: 'ar', label: 'North Africa (Arabic)', flag: '🇲🇦' },
{ value: 'fr', label: 'Europe (French)', flag: '🇫🇷' },
]
function getFooterContent(language: DashboardLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos de nous', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Conditions dutilisation', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Sécurité', href: `${marketplaceUrl}/footer/security` },
{ label: 'Conformité', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Politique de confidentialité', href: appPrivacyHref.fr },
{ label: 'Politique relative aux cookies', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contacter les ventes', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'Conditions générales', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'Europe (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'شروط الاستخدام', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'الأمان', href: `${marketplaceUrl}/footer/security` },
{ label: 'الامتثال', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'سياسة الخصوصية', href: appPrivacyHref.ar },
{ label: 'سياسة ملفات تعريف الارتباط', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'النشرة الإخبارية', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'تواصل مع المبيعات', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'الشروط العامة', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'North Africa (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About Us', href: `${marketplaceUrl}/footer/about-us` },
{ label: 'Terms of Service', href: `${marketplaceUrl}/footer/terms-of-service` },
{ label: 'Security', href: `${marketplaceUrl}/footer/security` },
{ label: 'Compliance', href: `${marketplaceUrl}/footer/compliance` },
{ label: 'Privacy Policy', href: appPrivacyHref.en },
{ label: 'Cookie Policy', href: `${marketplaceUrl}/footer/cookie-policy` },
],
secondary: [
{ label: 'Newsletter', href: `${marketplaceUrl}/footer/newsletter` },
{ label: 'Contact Sales', href: `${marketplaceUrl}/footer/contact-sales` },
{ label: 'General Conditions', href: `${marketplaceUrl}/footer/general-conditions` },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicFooter() {
const { language, setLanguage } = useDashboardI18n()
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
const currentLocale = localeOptions.find((option) => option.value === language) ?? localeOptions[0]
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<footer className="border-t border-stone-200/80 bg-white/72 px-4 py-8 text-stone-600 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/72 dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 px-0 text-center sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-orange-700 transition hover:text-blue-900 dark:text-orange-300 dark:hover:text-stone-100"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true" className="text-base leading-none">{currentLocale.flag}</span>
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true" className="text-base leading-none">{option.flag}</span>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-blue-900 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -0,0 +1,230 @@
'use client'
import { ChevronDown } from 'lucide-react'
import Image from 'next/image'
import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
export default function PublicNavbar() {
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const pathname = usePathname()
const searchParams = useSearchParams()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const themeMenuRef = useRef<HTMLDivElement | null>(null)
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
const currentLanguage = languageMeta[language]
const availableLanguages = (['en', 'fr', 'ar'] as const)
.filter((value) => value !== language)
.map((value) => ({ value, ...languageMeta[value] }))
const dict = {
en: {
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createAccount: 'Create Agency Space',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
},
fr: {
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createAccount: 'Créer un espace dagence',
theme: 'Mode',
light: 'Clair',
dark: 'Sombre',
},
ar: {
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createAccount: 'إنشاء مساحة الوكالة',
theme: 'الوضع',
light: 'فاتح',
dark: 'داكن',
},
}[language]
const themeOptions = [
{ value: 'light' as const, label: dict.light },
{ value: 'dark' as const, label: dict.dark },
]
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
const localeMenuPositionClass =
language === 'ar'
? 'right-0 sm:right-0'
: 'left-0 sm:left-auto sm:right-0'
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
if (!themeMenuRef.current?.contains(event.target as Node)) {
setThemeMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
function toggleLocaleMenu() {
setThemeMenuOpen(false)
setLocaleMenuOpen((open) => !open)
}
function toggleThemeMenu() {
setLocaleMenuOpen(false)
setThemeMenuOpen((open) => !open)
}
const signInParams = new URLSearchParams()
signInParams.set('lang', language)
signInParams.set('theme', theme)
const next = searchParams.get('next')
const redirect = searchParams.get('redirect')
if (next) signInParams.set('next', next)
if (redirect) signInParams.set('redirect', redirect)
const signInHref = `${marketplaceUrl}/sign-in?${signInParams.toString()}`
const signUpParams = new URLSearchParams()
signUpParams.set('lang', language)
signUpParams.set('theme', theme)
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
function navLinkClass(active: boolean) {
return `rounded-full px-2.5 py-1 text-[12px] font-medium transition sm:px-4 sm:py-2 sm:text-sm ${
active
? 'bg-blue-50 text-blue-950 ring-1 ring-blue-100 dark:bg-blue-900/55 dark:text-white dark:ring-blue-700/70'
: 'text-stone-600 hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100'
}`
}
const normalizedPathname = pathname.replace(/^\/dashboard(?=\/|$)/, '') || '/'
const signInActive = normalizedPathname === '/sign-in' || normalizedPathname.startsWith('/sign-in/')
const signUpActive = normalizedPathname === '/sign-up' || normalizedPathname.startsWith('/sign-up/')
return (
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
<div className="mx-auto flex max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
</a>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
<nav className="flex items-center gap-0.5 overflow-x-auto">
<a href={marketplaceUrl} className={navLinkClass(false)}>
{dict.home}
</a>
<a href={`${marketplaceUrl}/explore`} className={navLinkClass(false)}>
{dict.marketplace}
</a>
<a href={signInHref} className={navLinkClass(signInActive)} aria-current={signInActive ? 'page' : undefined}>
{dict.signIn}
</a>
<a
href={signUpHref}
aria-current={signUpActive ? 'page' : undefined}
className={`ml-1 rounded-full px-3 py-1 text-[12px] font-semibold transition sm:ml-2 sm:px-5 sm:py-2 sm:text-sm ${
signUpActive
? 'bg-orange-700 text-white ring-2 ring-orange-200 dark:bg-orange-300 dark:text-blue-950 dark:ring-orange-700/50'
: 'bg-blue-900 text-white hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300'
}`}
>
{dict.createAccount}
</a>
</nav>
<div className="shrink-0">
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
<div ref={localeMenuRef} className="relative">
<button
type="button"
onClick={toggleLocaleMenu}
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<span aria-hidden="true">{currentLanguage.flag}</span>
<span>{currentLanguage.shortLabel}</span>
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{availableLanguages.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span aria-hidden="true">{option.flag}</span>
<span>{option.shortLabel}</span>
</button>
))}
</div>
) : null}
</div>
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
<div ref={themeMenuRef} className="relative">
<button
type="button"
onClick={toggleThemeMenu}
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
aria-expanded={themeMenuOpen}
aria-haspopup="menu"
>
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
{currentTheme.label}
</span>
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{themeMenuOpen ? (
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
{themeOptions
.filter((option) => option.value !== theme)
.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setTheme(option.value)
setThemeMenuOpen(false)
}}
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
>
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,33 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('./PublicNavbar', () => ({ default: function MockPublicNavbar() { return React.createElement('mock-navbar') } }))
vi.mock('./PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicPageLayout from './PublicPageLayout'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
return typeof type === 'string' ? type : type.displayName ?? type.name ?? 'anonymous'
})
}
describe('PublicPageLayout', () => {
it('provides reusable public chrome for sign-in and account creation pages', () => {
const layout = PublicPageLayout({ children: React.createElement('main') })
expect(childTypes(layout)).toEqual(['MockPublicNavbar', 'div', 'MockPublicFooter'])
expect(layout.props['data-public-page-layout']).toBe('true')
})
it('allows either public chrome region to be disabled independently', () => {
const layout = PublicPageLayout({
children: React.createElement('main'),
showNavbar: false,
showFooter: true,
})
expect(childTypes(layout)).toEqual(['div', 'MockPublicFooter'])
})
})
@@ -0,0 +1,40 @@
'use client'
import type { ReactNode } from 'react'
import PublicFooter from './PublicFooter'
import PublicNavbar from './PublicNavbar'
export type PublicPageLayoutProps = {
children: ReactNode
embedded?: boolean
showNavbar?: boolean
showFooter?: boolean
className?: string
contentClassName?: string
}
/**
* Shared frame for dashboard pages that live outside the authenticated app shell.
* Use `embedded` for iframe/partner surfaces that must not render nested chrome.
*/
export default function PublicPageLayout({
children,
embedded = false,
showNavbar = true,
showFooter = true,
className = '',
contentClassName = '',
}: PublicPageLayoutProps) {
return (
<div
className={`flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] dark:text-slate-100 ${className}`.trim()}
data-public-page-layout="true"
data-embedded={embedded ? 'true' : 'false'}
>
{!embedded && showNavbar ? <PublicNavbar /> : null}
<div className={`flex flex-1 flex-col ${contentClassName}`.trim()}>{children}</div>
{!embedded && showFooter ? <PublicFooter /> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
'use client'
export { default as PublicFooter } from './PublicFooter'
export { default as PublicNavbar } from './PublicNavbar'
export { default as PublicPageLayout } from './PublicPageLayout'
export type { PublicPageLayoutProps } from './PublicPageLayout'
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
buildInspectionSavePayload,
comparisonPointsForCheckout,
editableDamagePointsForInspection,
pointKey,
type DamageInspection,
type DamagePoint,
} from './DamageInspectionCard'
function point(overrides: Partial<DamagePoint> = {}): DamagePoint {
return {
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: null,
isPreExisting: false,
...overrides,
}
}
function inspection(overrides: Partial<DamageInspection> = {}): DamageInspection {
return {
id: 'inspection-1',
type: 'CHECKOUT',
mileage: 45000,
fuelLevel: 'FULL',
fuelCharge: null,
generalCondition: 'Clean',
employeeNotes: 'No issues',
customerAgreed: true,
damagePoints: [],
...overrides,
}
}
describe('DamageInspectionCard helpers', () => {
it('builds stable duplicate keys using rounded coordinates and damage metadata', () => {
expect(pointKey(point({ x: 12.3456, y: 78.994 }))).toBe('TOP:12.35:78.99:SCRATCH:MINOR')
expect(pointKey(point({ viewType: 'LEFT', damageType: 'DENT', severity: 'MAJOR' }))).toBe('LEFT:10.00:20.00:DENT:MAJOR')
})
it('deduplicates checkout comparison points against stored pre-existing points', () => {
const duplicate = point({ id: 'cmp-1', isPreExisting: true, description: 'old scratch' })
const storedDuplicate = point({ id: 'stored-1', isPreExisting: true, description: 'stored copy' })
const storedUnique = point({ id: 'stored-2', viewType: 'REAR', x: 33, y: 44, isPreExisting: true })
const result = comparisonPointsForCheckout(
inspection({ damagePoints: [storedDuplicate, storedUnique] }),
[duplicate]
)
expect(result).toEqual([duplicate, storedUnique])
})
it('keeps checkout editing focused on new damage while check-in exposes all points', () => {
const oldDamage = point({ id: 'old', isPreExisting: true })
const newDamage = point({ id: 'new', isPreExisting: false })
const record = inspection({ damagePoints: [oldDamage, newDamage] })
expect(editableDamagePointsForInspection('CHECKOUT', record)).toEqual([newDamage])
expect(editableDamagePointsForInspection('CHECKIN', record)).toEqual([oldDamage, newDamage])
})
it('builds compact save payloads and omits nullable/empty fields', () => {
const payload = buildInspectionSavePayload(
inspection({
mileage: null,
fuelCharge: null,
generalCondition: '',
employeeNotes: '',
customerAgreed: false,
damagePoints: [point({ description: '' })],
}),
[point({ id: 'pre', viewType: 'FRONT', description: 'pre-existing chip', isPreExisting: true })]
)
expect(payload).toEqual({
fuelLevel: 'FULL',
customerAgreed: false,
damagePoints: [
{
viewType: 'FRONT',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: 'pre-existing chip',
isPreExisting: true,
},
{
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
isPreExisting: false,
},
],
})
})
})
@@ -0,0 +1,303 @@
'use client'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import VehicleConditionSheet from './VehicleConditionSheet'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
export interface DamagePoint {
id?: string
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
x: number
y: number
damageType: DamageType
severity: DamageSeverity
description?: string | null
isPreExisting: boolean
}
export interface DamageInspection {
id: string
type: InspectionType
mileage: number | null
fuelLevel: FuelLevel
fuelCharge: number | null
generalCondition: string | null
employeeNotes: string | null
customerAgreed: boolean
damagePoints: DamagePoint[]
}
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
const severityColors: Record<DamageSeverity, string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
export function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
}
export function comparisonPointsForCheckout(initialInspection: DamageInspection | null | undefined, comparisonPoints: DamagePoint[]): DamagePoint[] {
const storedPreExistingPoints = initialInspection?.damagePoints?.filter((point) => point.isPreExisting) ?? []
return [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
}
export function editableDamagePointsForInspection(type: InspectionType, initialInspection: DamageInspection | null | undefined): DamagePoint[] {
const points = initialInspection?.damagePoints ?? []
return type === 'CHECKOUT' ? points.filter((point) => !point.isPreExisting) : points
}
export function buildInspectionSavePayload(inspection: DamageInspection, overlayComparisonPoints: DamagePoint[]) {
const payloadPoints = [
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting: true,
})),
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
]
return {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: payloadPoints,
}
}
export default function DamageInspectionCard({
reservationId,
type,
initialInspection,
comparisonPoints = [],
editable = true,
readOnlyMessage,
onSaved,
}: {
reservationId: string
type: InspectionType
initialInspection?: DamageInspection | null
comparisonPoints?: DamagePoint[]
editable?: boolean
readOnlyMessage?: string | null
onSaved: (inspection: DamageInspection) => void
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const overlayComparisonPoints = type === 'CHECKOUT'
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
: []
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type,
mileage: initialInspection?.mileage ?? null,
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
fuelCharge: initialInspection?.fuelCharge ?? null,
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: editableDamagePointsForInspection(type, initialInspection),
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function saveInspection() {
setSaving(true)
setError(null)
try {
const payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
setInspection({
...result,
damagePoints: type === 'CHECKOUT'
? result.damagePoints.filter((point) => !point.isPreExisting)
: result.damagePoints,
})
onSaved(result)
} catch (err: any) {
setError(err.message ?? r.failedSaveInspection)
} finally {
setSaving(false)
}
}
function addDamagePoint(viewType: DamagePoint['viewType'], x: number, y: number) {
setInspection((current) => ({
...current,
damagePoints: [
...current.damagePoints,
{ viewType, x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
],
}))
}
function removePoint(index: number) {
setInspection((current) => ({
...current,
damagePoints: current.damagePoints.filter((_, i) => i !== index),
}))
}
return (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div>
<button onClick={saveInspection} disabled={saving || !editable} className="btn-primary">
{saving ? r.savingInspection : r.saveInspection}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{!editable && readOnlyMessage && (
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
{readOnlyMessage}
</div>
)}
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
{damageTypes.map((option) => (
<button
key={option}
type="button"
onClick={() => setDamageType(option)}
disabled={!editable}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-blue-700 text-white' : 'bg-white text-slate-600'}`}
>
{r.damageTypeLabels[option]}
</button>
))}
</div>
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
<button
key={option}
type="button"
onClick={() => setSeverity(option)}
disabled={!editable}
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
{r.severityLabels[option]}
</button>
))}
</div>
{type === 'CHECKOUT' && overlayComparisonPoints.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-100 px-3 py-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full border-2 border-dashed border-slate-500 bg-white" />
{r.inspectionCardTitle('CHECKIN')}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-blue-700" />
{r.inspectionCardTitle('CHECKOUT')}
</span>
</div>
)}
<VehicleConditionSheet
points={inspection.damagePoints}
comparisonPoints={overlayComparisonPoints}
interactive={editable}
onAddPoint={editable ? addDamagePoint : undefined}
className={`w-full rounded-2xl border border-slate-200 bg-white ${editable ? 'cursor-crosshair' : 'cursor-default'}`}
/>
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
<input
type="number"
className="input-field"
value={inspection.mileage ?? ''}
disabled={!editable}
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
</select>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
</div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
{r.customerAcknowledged}
</label>
<div className="rounded-2xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
</div>
<div className="divide-y divide-slate-100">
{inspection.damagePoints.map((point, index) => (
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
<p className="text-xs text-slate-500">{point.viewType} · x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div>
<button type="button" onClick={() => removePoint(index)} disabled={!editable} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 disabled:cursor-not-allowed disabled:opacity-50">
{r.removeMarker}
</button>
</div>
))}
{inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { apiFetch } from '@/lib/api'
interface ReservationPhoto {
id: string
type: 'PICKUP' | 'DROPOFF'
url: string
createdAt: string
}
interface Props {
reservationId: string
type: 'PICKUP' | 'DROPOFF'
editable: boolean
title: string
readOnlyMessage?: string
}
export default function ReservationPhotoSection({ reservationId, type, editable, title, readOnlyMessage }: Props) {
const [photos, setPhotos] = useState<ReservationPhoto[]>([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadPhotos = useCallback(async () => {
try {
const all = await apiFetch<ReservationPhoto[]>(`/reservations/${reservationId}/photos`)
setPhotos(all.filter((p) => p.type === type))
} catch {
// silent — don't block the page if photos fail to load
}
}, [reservationId, type])
useEffect(() => { loadPhotos() }, [loadPhotos])
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
setUploading(true)
setError(null)
try {
for (const file of Array.from(files)) {
const body = new FormData()
body.append('photo', file)
body.append('type', type)
await apiFetch(`/reservations/${reservationId}/photos`, { method: 'POST', body })
}
await loadPhotos()
} catch (err: any) {
setError(err.message ?? 'Upload failed')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
async function handleDelete(photoId: string) {
setError(null)
try {
await apiFetch(`/reservations/${reservationId}/photos/${photoId}`, { method: 'DELETE' })
setPhotos((prev) => prev.filter((p) => p.id !== photoId))
} catch (err: any) {
setError(err.message ?? 'Delete failed')
}
}
return (
<div className="card p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
{editable && (
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading…' : '+ Add photos'}
</button>
)}
</div>
{!editable && readOnlyMessage && (
<p className="mb-4 text-xs text-slate-500">{readOnlyMessage}</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{error && <p className="mb-3 text-xs text-red-600">{error}</p>}
{photos.length === 0 ? (
<p className="text-sm text-slate-400">No photos yet.</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-xl border border-slate-200">
<img src={photo.url} alt="" className="h-full w-full object-cover" />
{editable && (
<button
type="button"
onClick={() => handleDelete(photo.id)}
className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white group-hover:flex"
aria-label="Delete photo"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
SHEET_HEIGHT,
SHEET_WIDTH,
resolvePointPosition,
resolveVehicleSheetClick,
severityColors,
viewBoxes,
type VehicleSheetPoint,
} from './VehicleConditionSheet'
const rect = { left: 10, top: 20, width: SHEET_WIDTH * 2, height: SHEET_HEIGHT * 2 } as Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>
function sheetClientPoint(x: number, y: number) {
return {
clientX: rect.left + (x / SHEET_WIDTH) * rect.width,
clientY: rect.top + (y / SHEET_HEIGHT) * rect.height,
}
}
describe('VehicleConditionSheet geometry helpers', () => {
it('maps percentage positions into absolute sheet coordinates per view', () => {
expect(resolvePointPosition({ viewType: 'TOP', x: 50, y: 50, severity: 'MINOR' })).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w / 2,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h / 2,
})
expect(resolvePointPosition({ viewType: 'LEFT', x: 0, y: 100, severity: 'MAJOR' })).toEqual({
cx: viewBoxes.LEFT.x,
cy: viewBoxes.LEFT.y + viewBoxes.LEFT.h,
})
})
it('defaults missing viewType to TOP for legacy damage points', () => {
const legacyPoint = { x: 25, y: 10, severity: 'MODERATE' } satisfies VehicleSheetPoint
expect(resolvePointPosition(legacyPoint)).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w * 0.25,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h * 0.1,
})
})
it('resolves clicks inside each vehicle region to local percentages', () => {
const topCenter = sheetClientPoint(viewBoxes.TOP.x + viewBoxes.TOP.w / 2, viewBoxes.TOP.y + viewBoxes.TOP.h / 2)
const frontCorner = sheetClientPoint(viewBoxes.FRONT.x, viewBoxes.FRONT.y)
const rearCorner = sheetClientPoint(viewBoxes.REAR.x + viewBoxes.REAR.w, viewBoxes.REAR.y + viewBoxes.REAR.h)
expect(resolveVehicleSheetClick(topCenter.clientX, topCenter.clientY, rect)).toEqual({ viewType: 'TOP', x: 50, y: 50 })
expect(resolveVehicleSheetClick(frontCorner.clientX, frontCorner.clientY, rect)).toEqual({ viewType: 'FRONT', x: 0, y: 0 })
expect(resolveVehicleSheetClick(rearCorner.clientX, rearCorner.clientY, rect)).toEqual({ viewType: 'REAR', x: 100, y: 100 })
})
it('rejects clicks outside vehicle regions instead of creating impossible damage points', () => {
const outside = sheetClientPoint(40, 40)
expect(resolveVehicleSheetClick(outside.clientX, outside.clientY, rect)).toBeNull()
})
it('keeps severity color mapping explicit for rendering and comparison markers', () => {
expect(severityColors).toEqual({
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
})
})
})
@@ -0,0 +1,119 @@
'use client'
export type VehicleSheetView = 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
export type VehicleSheetPoint = {
viewType?: VehicleSheetView
x: number
y: number
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
}
export const SHEET_WIDTH = 573
export const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
export const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
export const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
FRONT: { x: 161, y: 4, w: 251, h: 108 },
TOP: { x: 150, y: 112, w: 273, h: 613 },
REAR: { x: 156, y: 724, w: 252, h: 123 },
LEFT: { x: 4, y: 114, w: 146, h: 618 },
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
}
export function resolvePointPosition(point: VehicleSheetPoint) {
const viewType = point.viewType ?? 'TOP'
const box = viewBoxes[viewType]
return {
cx: box.x + (point.x / 100) * box.w,
cy: box.y + (point.y / 100) * box.h,
}
}
export function resolveVehicleSheetClick(clientX: number, clientY: number, rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): { viewType: VehicleSheetView; x: number; y: number } | null {
const x = ((clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((clientY - rect.top) / rect.height) * SHEET_HEIGHT
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>).find(([, box]) =>
x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h
)
if (!target) return null
const [viewType, box] = target
return {
viewType,
x: ((x - box.x) / box.w) * 100,
y: ((y - box.y) / box.h) * 100,
}
}
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
const { cx, cy } = resolvePointPosition(point)
const color = severityColors[point.severity]
if (variant === 'comparison') {
return (
<g key={`comparison-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}>
<circle cx={cx} cy={cy} r="9" fill="rgba(255,255,255,0.92)" stroke={color} strokeWidth="2.5" strokeDasharray="5 4" />
<circle cx={cx} cy={cy} r="4" fill={color} opacity="0.28" />
</g>
)
}
return (
<circle
key={`current-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}
cx={cx}
cy={cy}
r="7.5"
fill={color}
stroke="white"
strokeWidth="2.5"
/>
)
}
export default function VehicleConditionSheet({
points,
comparisonPoints = [],
interactive = false,
className = '',
onAddPoint,
}: {
points: VehicleSheetPoint[]
comparisonPoints?: VehicleSheetPoint[]
interactive?: boolean
className?: string
onAddPoint?: (viewType: VehicleSheetView, x: number, y: number) => void
}) {
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
if (!interactive || !onAddPoint) return
const resolved = resolveVehicleSheetClick(event.clientX, event.clientY, event.currentTarget.getBoundingClientRect())
if (!resolved) return
onAddPoint(resolved.viewType, resolved.x, resolved.y)
}
return (
<svg
viewBox={`0 0 ${SHEET_WIDTH} ${SHEET_HEIGHT}`}
className={className}
onClick={handleClick}
>
<rect x="1.5" y="1.5" width={SHEET_WIDTH - 3} height={SHEET_HEIGHT - 3} rx="18" fill="#fff" stroke="#cbd5e1" strokeWidth="3" />
<image href={SHEET_IMAGE_PATH} x="0" y="0" width={SHEET_WIDTH} height={SHEET_HEIGHT} preserveAspectRatio="xMidYMid meet" />
{comparisonPoints.map((point, index) => renderMarker(point, index, 'comparison'))}
{points.map((point, index) => renderMarker(point, index, 'current'))}
</svg>
)
}
@@ -0,0 +1,339 @@
'use client'
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
member: TeamMember | null
open: boolean
onClose: () => void
onUpdateRole: (memberId: string, role: 'MANAGER' | 'AGENT') => Promise<void>
onDeactivate: (memberId: string) => Promise<void>
onReactivate: (memberId: string) => Promise<void>
onRemove: (memberId: string) => Promise<void>
}
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
const copy = {
en: {
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
role: 'Role',
cancel: 'Cancel',
saveChanges: 'Save changes',
saving: 'Saving…',
working: 'Working…',
deactivate: 'Deactivate',
reactivate: 'Reactivate',
remove: 'Remove',
removePermanently: 'Remove permanently',
confirmDeactivateTitle: 'Deactivate this member?',
confirmRemoveTitle: 'Permanently remove this member?',
confirmReactivateTitle: 'Reactivate this member?',
confirmDeactivateDesc: "They'll immediately lose dashboard access. You can reactivate anytime.",
confirmRemoveDesc: "This will delete the employee record and revoke their dashboard access. This cannot be undone.",
confirmReactivateDesc: "They'll regain dashboard access with their current role.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
],
},
fr: {
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
role: 'Rôle',
cancel: 'Annuler',
saveChanges: 'Enregistrer',
saving: 'Enregistrement…',
working: 'En cours…',
deactivate: 'Désactiver',
reactivate: 'Réactiver',
remove: 'Supprimer',
removePermanently: 'Supprimer définitivement',
confirmDeactivateTitle: 'Désactiver ce membre ?',
confirmRemoveTitle: 'Supprimer définitivement ce membre ?',
confirmReactivateTitle: 'Réactiver ce membre ?',
confirmDeactivateDesc: "Il perdra immédiatement l'accès au tableau de bord. Vous pouvez le réactiver à tout moment.",
confirmRemoveDesc: "Cela supprimera le compte et révoquera son accès au tableau de bord. Cette action est irréversible.",
confirmReactivateDesc: "Il retrouvera l'accès au tableau de bord avec son rôle actuel.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Opérations complètes — sans facturation' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Réservations et enregistrements' },
],
},
ar: {
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
role: 'الدور',
cancel: 'إلغاء',
saveChanges: 'حفظ التغييرات',
saving: 'جارٍ الحفظ…',
working: 'جارٍ التنفيذ…',
deactivate: 'تعطيل',
reactivate: 'إعادة التفعيل',
remove: 'حذف',
removePermanently: 'حذف نهائياً',
confirmDeactivateTitle: 'تعطيل هذا العضو؟',
confirmRemoveTitle: 'حذف هذا العضو نهائياً؟',
confirmReactivateTitle: 'إعادة تفعيل هذا العضو؟',
confirmDeactivateDesc: 'سيفقد الوصول إلى لوحة التحكم فوراً. يمكنك إعادة التفعيل في أي وقت.',
confirmRemoveDesc: 'سيتم حذف سجل الموظف وإلغاء صلاحياته. هذا الإجراء لا يمكن التراجع عنه.',
confirmReactivateDesc: 'سيستعيد الوصول إلى لوحة التحكم بدوره الحالي.',
roleOptions: [
{ value: 'MANAGER' as const, label: 'مدير', description: 'عمليات كاملة — بدون فوترة' },
{ value: 'AGENT' as const, label: 'وكيل', description: 'الحجوزات والاستلام' },
],
},
}
export default function EditMemberModal({
member,
open,
onClose,
onUpdateRole,
onDeactivate,
onReactivate,
onRemove,
}: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (member && open) {
setRole(member.role === 'OWNER' ? 'MANAGER' : (member.role as 'MANAGER' | 'AGENT'))
setActionState('idle')
setConfirm(null)
setError(null)
}
}, [member, open])
if (!open || !member) return null
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
const handleSave = async () => {
if (role === member.role) { onClose(); return }
setActionState('saving')
setError(null)
try {
await onUpdateRole(member.id, role)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
}
const handleDeactivate = async () => {
setActionState('deactivating')
setError(null)
try {
await onDeactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleReactivate = async () => {
setActionState('reactivating')
setError(null)
try {
await onReactivate(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
const handleRemove = async () => {
setActionState('removing')
setError(null)
try {
await onRemove(member.id)
onClose()
} catch (err: any) {
setError(err.message)
setActionState('idle')
}
setConfirm(null)
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
</div>
<div>
<p className="font-medium text-zinc-900 dark:text-white text-sm">
{member.firstName} {member.lastName}
</p>
<p className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</p>
</div>
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border border-orange-100 dark:border-orange-900/50">
{t.pendingInvite}
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
{t.deactivated}
</span>
)}
</div>
</div>
{/* Confirm panel */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && t.confirmDeactivateTitle}
{confirm === 'remove' && t.confirmRemoveTitle}
{confirm === 'reactivate' && t.confirmReactivateTitle}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && t.confirmDeactivateDesc}
{confirm === 'remove' && t.confirmRemoveDesc}
{confirm === 'reactivate' && t.confirmReactivateDesc}
</p>
<div className={`flex gap-2 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
{t.cancel}
</button>
<button
onClick={
confirm === 'deactivate' ? handleDeactivate :
confirm === 'reactivate' ? handleReactivate :
handleRemove
}
disabled={busy}
className={[
'flex-1 text-xs px-3 py-1.5 rounded-lg font-medium disabled:opacity-50',
confirm === 'reactivate'
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? t.working : (
confirm === 'deactivate' ? t.deactivate :
confirm === 'reactivate' ? t.reactivate :
t.removePermanently
)}
</button>
</div>
</div>
)}
{/* Role selector — only for active, non-pending members */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-2.5 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="text-sm font-medium text-zinc-900 dark:text-white">{option.label}</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{option.description}</div>
</button>
))}
</div>
</div>
)}
{error && (
<div className="mb-4 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Actions footer */}
<div className={`flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-2 ${isRtl ? 'ml-auto' : 'mr-auto'}`}>
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
{t.deactivate}
</button>
)}
{!isActive && (
<button
onClick={() => setConfirm('reactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
{t.reactivate}
</button>
)}
<button
onClick={() => setConfirm('remove')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
{t.remove}
</button>
</div>
<button
onClick={onClose}
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
{t.cancel}
</button>
{isActive && !isPending && (
<button
onClick={handleSave}
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? t.saving : t.saveChanges}
</button>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,273 @@
'use client'
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
open: boolean
onClose: () => void
onInvite: (payload: InvitePayload) => Promise<void>
}
const copy = {
en: {
title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your workspace.",
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
sendInvite: 'Send invite →',
failedInvite: 'Failed to send invitation',
more: (n: number) => `+${n} more`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet operations, without billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
],
},
fr: {
title: 'Inviter un membre',
subtitle: "Cette personne recevra un e-mail d'invitation pour rejoindre votre espace de travail.",
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
sendInvite: "Envoyer l'invitation →",
failedInvite: "Échec de l'envoi de l'invitation",
more: (n: number) => `+${n} de plus`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Opérations complètes, sans accès à la facturation ni aux paramètres de l’équipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation des permis'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Opérations quotidiennes uniquement',
permissions: ['Voir la flotte', 'Réservations', 'Départ / Retour', 'Voir les clients'],
},
],
},
ar: {
title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى رسالة بريد إلكتروني للانضمام إلى مساحة العمل.',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
sendInvite: '← إرسال الدعوة',
failedInvite: 'فشل إرسال الدعوة',
more: (n: number) => `+${n} أكثر`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'مدير',
description: 'صلاحيات تشغيل كاملة للأسطول، من دون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
},
{
value: 'AGENT' as const,
label: 'وكيل',
description: 'العمليات اليومية فقط',
permissions: ['عرض الأسطول', 'الحجوزات', 'الاستلام / الإرجاع', 'عرض العملاء'],
},
],
},
}
export default function InviteModal({ open, onClose, onInvite }: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setTimeout(() => {
setFirstName('')
setLastName('')
setEmail('')
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? t.failedInvite)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">{t.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t.subtitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.firstName}
</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.lastName}
</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.email}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setRole(option.value)}
className={[
'text-left px-3 py-3 rounded-xl border transition-all',
role === option.value
? 'border-zinc-900 dark:border-white bg-zinc-50 dark:bg-zinc-800'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600',
].join(' ')}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-zinc-900 dark:text-white">
{option.label}
</span>
{role === option.value && (
<span className="w-4 h-4 rounded-full bg-zinc-900 dark:bg-white flex items-center justify-center">
<svg className="w-2.5 h-2.5 text-white dark:text-zinc-900" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</span>
)}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-400 leading-snug">
{option.description}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{option.permissions.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400"
>
{p}
</span>
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
{t.more(option.permissions.length - 3)}
</span>
)}
</div>
</button>
))}
</div>
</div>
{error && (
<div className="px-3 py-2.5 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className={`flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{t.cancel}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? t.sending : t.sendInvite}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,225 @@
'use client'
import { useDashboardI18n } from '@/components/I18nProvider'
type Access = 'full' | 'partial' | 'none'
interface Feature {
manager: Access
managerNote?: string
agent: Access
agentNote?: string
}
const FEATURES: Feature[] = [
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
]
const copy = {
en: {
heading: 'Role permissions',
subtitle: 'What each role can do across the dashboard. Owners have full access to everything.',
colFeature: 'Feature',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Full',
noAccess: 'No access',
limited: 'Limited',
featureLabels: [
'View fleet & vehicles',
'Add / edit vehicles',
'Publish / unpublish vehicle',
'Upload vehicle photos',
'View reservations',
'Create reservations',
'Cancel reservations',
'Check-in / check-out',
'Damage inspection',
'Customer CRM — view',
'Customer CRM — edit',
'Approve driver licenses',
'Flag / blacklist customer',
'Offers & promotions',
'Analytics & reports',
'Contract & invoice PDF',
'Billing & subscription',
'Invite / remove staff',
'Brand & site settings',
'Payment settings',
],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
},
fr: {
heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans lespace de travail. Les propriétaires ont un accès complet à lensemble du système.',
colFeature: 'Fonctionnalité',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Complet',
noAccess: 'Aucun accès',
limited: 'Limité',
featureLabels: [
'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule',
'Téléverser des photos de véhicule',
'Voir les réservations',
'Créer des réservations',
'Annuler des réservations',
'Départ / retour',
'Inspection des dommages',
'CRM clients — consultation',
'CRM clients — modification',
'Approuver les permis de conduire',
'Signaler / bloquer un client',
'Offres et promotions',
'Analyses et rapports',
'Contrat et facture PDF',
'Facturation et abonnement',
'Inviter / supprimer des membres de l’équipe',
'Paramètres marque et site',
'Paramètres de paiement',
],
agentNotes: ['', '', '', '', '', '', 'Les siennes uniquement', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
},
ar: {
heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به داخل مساحة العمل. يملك المالكون صلاحية كاملة على كل شيء.',
colFeature: 'الميزة',
colManager: 'المدير',
colAgent: 'الوكيل',
full: 'كامل',
noAccess: 'لا وصول',
limited: 'محدود',
featureLabels: [
'عرض الأسطول والمركبات',
'إضافة / تعديل المركبات',
'نشر / إلغاء نشر المركبة',
'رفع صور المركبة',
'عرض الحجوزات',
'إنشاء الحجوزات',
'إلغاء الحجوزات',
'الاستلام / الإرجاع',
'فحص الأضرار',
'إدارة العملاء — عرض',
'إدارة العملاء — تعديل',
'الموافقة على رخص القيادة',
'تمييز العميل / حظره',
'العروض والحملات الترويجية',
'التحليلات والتقارير',
'العقد والفاتورة بصيغة PDF',
'الفوترة والاشتراك',
'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع',
'إعدادات الدفع',
],
agentNotes: ['', '', '', '', '', '', 'حجوزاته فقط', '', '', '', 'الملاحظات فقط', '', '', '', '', '', '', '', '', ''],
},
}
function AccessCell({ access, label, limitedNote }: { access: Access; label: { full: string; noAccess: string; limited: string }; limitedNote: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">{label.full}</span>
</div>
)
}
if (access === 'partial') {
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-orange-500" />
</div>
<span className="text-xs text-orange-700 dark:text-orange-400">{limitedNote || label.limited}</span>
</div>
)
}
return (
<div className="flex items-center gap-1.5">
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">{label.noAccess}</span>
</div>
)
}
export default function PermissionsMatrix() {
const { language } = useDashboardI18n()
const t = copy[language]
return (
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">{t.heading}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{t.subtitle}</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
{t.colFeature}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
{t.colManager}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
{t.colAgent}
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={i}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
i % 2 === 0 ? '' : 'bg-zinc-50/50 dark:bg-zinc-800/20',
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{t.featureLabels[i]}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell
access={feature.manager}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={feature.managerNote ?? ''}
/>
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell
access={feature.agent}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={t.agentNotes[i] ?? ''}
/>
</div>
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { bilingualPrimary, emptyBilingual } from './BilingualInput'
describe('bilingual field helpers', () => {
it('returns a stable empty field shape for French and Arabic labels', () => {
expect(emptyBilingual()).toEqual({ fr: '', ar: '' })
expect(emptyBilingual()).not.toBe(emptyBilingual())
})
it('prefers French as the primary display value when both translations exist', () => {
expect(bilingualPrimary({ fr: 'Assurance', ar: 'تأمين' })).toBe('Assurance')
})
it('falls back to Arabic when the French value is empty', () => {
expect(bilingualPrimary({ fr: '', ar: 'تأمين' })).toBe('تأمين')
})
it('does not trim values implicitly so form state remains lossless', () => {
expect(bilingualPrimary({ fr: ' Service premium ', ar: 'خدمة' })).toBe(' Service premium ')
expect(bilingualPrimary({ fr: '', ar: ' خدمة ' })).toBe(' خدمة ')
})
})
@@ -0,0 +1,66 @@
'use client'
export type BilingualField = { fr: string; ar: string }
export function emptyBilingual(): BilingualField {
return { fr: '', ar: '' }
}
export function bilingualPrimary(field: BilingualField): string {
return field.fr || field.ar
}
export function BilingualInput({
label,
value,
onChange,
required = false,
maxLength,
}: {
label: string
value: BilingualField
onChange: (value: BilingualField) => void
required?: boolean
maxLength?: number
}) {
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-500">
FR
</span>
<input
type="text"
dir="ltr"
lang="fr"
maxLength={maxLength}
placeholder="Français…"
value={value.fr}
onChange={(e) => onChange({ ...value, fr: e.target.value })}
className="input-field pl-11"
/>
</div>
<div className="relative">
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600">
AR
</span>
<input
type="text"
dir="rtl"
lang="ar"
maxLength={maxLength}
placeholder="بالعربية…"
value={value.ar}
onChange={(e) => onChange({ ...value, ar: e.target.value })}
className="input-field pr-11 text-right"
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,72 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import StatCard from './StatCard'
import type { LucideIcon } from 'lucide-react'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
function FakeIcon(props: { className?: string }) {
return React.createElement('svg', props)
}
const icon = FakeIcon as unknown as LucideIcon
describe('StatCard', () => {
it('renders title, numeric value, and positive changes with a plus sign', () => {
const element = StatCard({ title: 'Revenue', value: 12450, change: 8.238, icon })
const text = collectText(element)
const renderedText = text.join('')
expect(text).toContain('Revenue')
expect(text).toContain('12450')
expect(renderedText).toContain('+8.2%')
expect(text).toContain('vs last month')
})
it('renders negative changes without forcing a positive marker', () => {
const element = StatCard({ title: 'Cancellations', value: '12', change: -3.15, vsLastMonthLabel: 'vs previous period', icon })
const text = collectText(element)
const renderedText = text.join('')
expect(renderedText).toContain('-3.1%')
expect(text).toContain('vs previous period')
expect(renderedText).not.toContain('+-3.1')
})
it('omits the trend row when no change is provided', () => {
const element = StatCard({ title: 'Active cars', value: 31, icon })
const text = collectText(element)
expect(text).toContain('Active cars')
expect(text).toContain('31')
expect(text).not.toContain('vs last month')
expect(text.join('')).not.toContain('%')
})
it('passes custom icon color and background classes to the rendered icon shell', () => {
const element = StatCard({
title: 'Fleet',
value: 9,
icon,
iconColor: 'text-violet-700',
iconBg: 'bg-violet-50',
})
const elements = collectElements(element)
expect(elements.some((node) => String(node.props.className ?? '').includes('bg-violet-50'))).toBe(true)
expect(elements.some((node) => String(node.props.className ?? '').includes('text-violet-700'))).toBe(true)
})
})
+87
View File
@@ -0,0 +1,87 @@
import * as React from "react";
import { LucideIcon } from 'lucide-react'
interface StatCardProps {
title: string
value: string | number
change?: number
vsLastMonthLabel?: string
icon: LucideIcon
iconColor?: string
iconBg?: string
/** Optional target value to show a progress bar beneath the value */
target?: number
/** Current progress (0100) when target is set */
progressPercent?: number
/** Show a subtle pulse/glow animation on the card */
pulse?: boolean
/** Apply a gradient to the value text */
valueGradient?: boolean
}
export default function StatCard({
title,
value,
change,
vsLastMonthLabel = 'vs last month',
icon: Icon,
iconColor = 'text-orange-700 dark:text-orange-300',
iconBg = 'bg-orange-50 dark:bg-orange-950/30',
target,
progressPercent,
pulse,
valueGradient,
}: StatCardProps) {
const isPositive = change !== undefined && change >= 0
const isNegative = change !== undefined && change < 0
return (
<div className={`card relative overflow-hidden p-5 ${pulse ? 'pulse-glow' : ''}`}>
<span className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-blue-700 to-orange-600 opacity-80" aria-hidden="true" />
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{title}</p>
<p
className={[
'mt-3 text-[34px] font-black leading-none tracking-[-0.035em]',
valueGradient
? 'bg-gradient-to-br from-blue-950 via-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-slate-100 dark:via-blue-200 dark:to-orange-300'
: 'bg-gradient-to-br from-blue-950 to-slate-700 bg-clip-text text-transparent dark:from-slate-100 dark:to-slate-300',
].join(' ')}
>
{value}
</p>
{target !== undefined && progressPercent !== undefined && (
<div className="mt-2 flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill orange"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{Math.round(progressPercent)}%
</span>
</div>
)}
{change !== undefined && (
<div className="mt-2 flex items-center gap-1">
<span
className={[
'text-xs font-medium',
isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-slate-500 dark:text-slate-400',
].join(' ')}
>
{isPositive ? '+' : ''}{change.toFixed(1)}%
</span>
<span className="text-xs text-slate-500 dark:text-slate-500">{vsLastMonthLabel}</span>
</div>
)}
</div>
<div className={`flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[var(--rdg-radius-sm)] ${iconBg}`}>
<Icon className={`h-6 w-6 ${iconColor}`} />
</div>
</div>
</div>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
appendInvitedMember,
applyActivationStats,
applyMemberActivation,
applyMemberRoleUpdate,
incrementInvitedStats,
removeTeamMemberState,
type TeamMember,
type TeamStats,
} from './useTeam'
function member(overrides: Partial<TeamMember>): TeamMember {
return {
id: 'member-1',
firstName: 'Amina',
lastName: 'Bennis',
email: 'amina@example.com',
phone: null,
role: 'AGENT',
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
lastActiveAt: null,
invitationStatus: 'accepted',
...overrides,
}
}
const stats: TeamStats = { total: 3, active: 2, pending: 1, inactive: 0 }
describe('useTeam state helpers', () => {
it('appends invited employees and increments the pending counters immutably', () => {
const existing = [member({ id: 'owner', role: 'OWNER' })]
const invited = member({ id: 'invite-1', role: 'MANAGER', invitationStatus: 'pending', isActive: false })
expect(appendInvitedMember(existing, invited)).toEqual([...existing, invited])
expect(existing).toHaveLength(1)
expect(incrementInvitedStats(stats)).toEqual({ total: 4, active: 2, pending: 2, inactive: 0 })
})
it('updates roles and activation flags only on the targeted member', () => {
const members = [member({ id: 'a' }), member({ id: 'b', role: 'MANAGER', isActive: false })]
expect(applyMemberRoleUpdate(members, 'a', 'MANAGER')).toEqual([
{ ...members[0], role: 'MANAGER' },
members[1],
])
expect(applyMemberActivation(members, 'b', true)).toEqual([
members[0],
{ ...members[1], isActive: true },
])
})
it('moves counters between active and inactive for deactivate/reactivate actions', () => {
expect(applyActivationStats({ total: 5, active: 4, pending: 0, inactive: 1 }, 'deactivate')).toEqual({
total: 5,
active: 3,
pending: 0,
inactive: 2,
})
expect(applyActivationStats({ total: 5, active: 3, pending: 0, inactive: 2 }, 'reactivate')).toEqual({
total: 5,
active: 4,
pending: 0,
inactive: 1,
})
})
it('removes active, pending, and inactive accepted members with the correct counter impact', () => {
const members = [
member({ id: 'active', isActive: true, invitationStatus: 'accepted' }),
member({ id: 'pending', isActive: false, invitationStatus: 'pending' }),
member({ id: 'inactive', isActive: false, invitationStatus: 'accepted' }),
]
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'active').stats).toEqual({
total: 2,
active: 0,
pending: 1,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'pending').stats).toEqual({
total: 2,
active: 1,
pending: 0,
inactive: 1,
})
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'inactive').stats).toEqual({
total: 2,
active: 1,
pending: 1,
inactive: 0,
})
})
it('leaves counters untouched when asked to remove an unknown member', () => {
const members = [member({ id: 'only' })]
const result = removeTeamMemberState(members, { total: 1, active: 1, pending: 0, inactive: 0 }, 'missing')
expect(result.members).toEqual(members)
expect(result.stats).toEqual({ total: 1, active: 1, pending: 0, inactive: 0 })
})
})
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { apiFetch } from '@/lib/api'
export type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
export type InvitationStatus = 'accepted' | 'pending' | 'revoked'
export interface TeamMember {
id: string
firstName: string
lastName: string
email: string
phone: string | null
role: EmployeeRole
isActive: boolean
createdAt: string
updatedAt: string
lastActiveAt: string | null
invitationStatus: InvitationStatus
}
export interface TeamStats {
total: number
active: number
pending: number
inactive: number
}
export interface InvitePayload {
firstName: string
lastName: string
email: string
role: 'MANAGER' | 'AGENT'
}
export function appendInvitedMember(members: TeamMember[], employee: TeamMember): TeamMember[] {
return [...members, employee]
}
export function incrementInvitedStats(stats: TeamStats): TeamStats {
return { ...stats, total: stats.total + 1, pending: stats.pending + 1 }
}
export function applyMemberRoleUpdate(members: TeamMember[], memberId: string, role: 'MANAGER' | 'AGENT'): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, role } : member))
}
export function applyMemberActivation(members: TeamMember[], memberId: string, isActive: boolean): TeamMember[] {
return members.map((member) => (member.id === memberId ? { ...member, isActive } : member))
}
export function applyActivationStats(stats: TeamStats, direction: 'deactivate' | 'reactivate'): TeamStats {
return direction === 'deactivate'
? { ...stats, active: stats.active - 1, inactive: stats.inactive + 1 }
: { ...stats, active: stats.active + 1, inactive: stats.inactive - 1 }
}
export function removeTeamMemberState(members: TeamMember[], stats: TeamStats, memberId: string): { members: TeamMember[]; stats: TeamStats } {
const target = members.find((member) => member.id === memberId)
return {
members: members.filter((member) => member.id !== memberId),
stats: {
...stats,
total: stats.total - (target ? 1 : 0),
active: target?.isActive ? stats.active - 1 : stats.active,
pending: target?.invitationStatus === 'pending' ? stats.pending - 1 : stats.pending,
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
? stats.inactive - 1
: stats.inactive,
},
}
}
export function useTeam() {
const [members, setMembers] = useState<TeamMember[]>([])
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refetch = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [membersData, statsData] = await Promise.all([
apiFetch<TeamMember[]>('/team'),
apiFetch<TeamStats>('/team/stats'),
])
setMembers(membersData)
setStats(statsData)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { refetch() }, [refetch])
const invite = useCallback(async (payload: InvitePayload) => {
const result = await apiFetch<{ employee: TeamMember; invitationId: string }>(
'/team/invite',
{ method: 'POST', body: JSON.stringify(payload) }
)
setMembers((prev) => appendInvitedMember(prev, result.employee))
setStats(incrementInvitedStats)
return result
}, [])
const updateRole = useCallback(async (memberId: string, role: 'MANAGER' | 'AGENT') => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
setMembers((prev) => applyMemberRoleUpdate(prev, memberId, updated.role as 'MANAGER' | 'AGENT'))
return updated
}, [])
const deactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
method: 'POST',
})
setMembers((prev) => applyMemberActivation(prev, memberId, false))
setStats((prev) => applyActivationStats(prev, 'deactivate'))
return updated
}, [])
const reactivate = useCallback(async (memberId: string) => {
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
method: 'POST',
})
setMembers((prev) => applyMemberActivation(prev, memberId, true))
setStats((prev) => applyActivationStats(prev, 'reactivate'))
return updated
}, [])
const remove = useCallback(async (memberId: string) => {
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
method: 'DELETE',
})
const nextState = removeTeamMemberState(members, stats, memberId)
setMembers(nextState.members)
setStats(nextState.stats)
}, [members, stats])
return {
members,
stats,
loading,
error,
refetch,
invite,
updateRole,
deactivate,
reactivate,
remove,
}
}
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installBrowser(token?: string) {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: vi.fn(() => token ?? null),
},
},
})
}
afterEach(() => {
vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
})
describe('dashboard apiFetch', () => {
it('adds JSON headers and sends cookies for browser requests', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { ok: true } }),
}))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/team$/), expect.objectContaining({
credentials: 'include',
headers: expect.objectContaining({
'Content-Type': 'application/json',
}),
}))
})
it('does not force JSON content type for FormData payloads', async () => {
installBrowser()
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetch } = await import('./api')
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
})
it('surfaces API error details on failed responses', async () => {
installBrowser()
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
value: vi.fn(async () => ({
ok: false,
status: 403,
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
})),
})
const { apiFetch } = await import('./api')
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
})
it('uses explicit server token and disables caching for server requests', async () => {
Reflect.deleteProperty(globalThis, 'window')
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { apiFetchServer } = await import('./api')
await apiFetchServer('/fleet', 'server-token')
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
cache: 'no-store',
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
}))
})
})
+68
View File
@@ -0,0 +1,68 @@
export const API_BASE =
typeof window === 'undefined'
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const headers: Record<string, string> = {
...(options?.headers as Record<string, string> ?? {}),
}
if (!isFormData) {
headers['Content-Type'] = 'application/json'
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
credentials: 'include',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
err.code = json?.error
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
'Authorization': `Bearer ${token}`,
...(options?.headers as Record<string, string> ?? {}),
},
cache: 'no-store',
})
let json: any
try {
json = await res.json()
} catch {
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
}
if (!res.ok) {
const err = new Error(json?.message ?? `Request failed with status ${res.status}`) as any
err.statusCode = res.status
throw err
}
return (json?.data ?? json) as T
}
+34
View File
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest'
import { resolveBrowserAppUrl } from './appUrls'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
})
describe('dashboard resolveBrowserAppUrl', () => {
it('returns the fallback untouched on the server', () => {
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
})
it('keeps local browser fallbacks local and removes trailing slash', () => {
installWindow('localhost')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
})
it('rewrites fallback origin to the current production host', () => {
installWindow('tenant.example.com', 'https:')
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
})
it('falls back safely when the configured value is not a URL', () => {
installWindow('tenant.example.com')
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
})
})
+15
View File
@@ -0,0 +1,15 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
const h = window.location.hostname
if (h === 'localhost' || h === '127.0.0.1') return fallback.replace(/\/$/, '')
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = h
target.port = ''
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
describe('dashboard path normalization', () => {
it('collapses empty and root values to the app root', () => {
expect(toDashboardAppPath()).toBe('/')
expect(toDashboardAppPath('')).toBe('/')
expect(toDashboardAppPath('/')).toBe('/')
})
it('strips repeated dashboard base prefixes', () => {
expect(toDashboardAppPath('/dashboard')).toBe('/')
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
})
it('adds the public deployment prefix exactly once', () => {
expect(toPublicDashboardPath('/')).toBe('/dashboard')
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
})
})
+19
View File
@@ -0,0 +1,19 @@
const DASHBOARD_BASE_PATH = '/dashboard'
export function toDashboardAppPath(path?: string | null): string {
const value = (path ?? '').trim()
if (!value || value === '/') return '/'
let normalized = value.startsWith('/') ? value : `/${value}`
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
export function toPublicDashboardPath(path?: string | null): string {
const appPath = toDashboardAppPath(path)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
+96
View File
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
getScopedPreferenceCookieName,
getScopedPreferenceKey,
readCurrentUserScopedPreference,
readScopedPreference,
writeScopedPreference,
} from './preferences'
function installBrowser(cookie = '') {
const store = new Map<string, string>()
let cookieValue = cookie
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
},
},
})
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
get cookie() {
return cookieValue
},
set cookie(value: string) {
const [pair] = value.split(';')
const [name, val] = pair.split('=')
const existing = cookieValue
.split(';')
.map((chunk) => chunk.trim())
.filter(Boolean)
.filter((chunk) => !chunk.startsWith(`${name}=`))
existing.push(`${name}=${val}`)
cookieValue = existing.join('; ')
},
},
})
Object.defineProperty(globalThis, 'atob', {
configurable: true,
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
})
return { store, get cookie() { return cookieValue } }
}
beforeEach(() => {
installBrowser()
})
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
Reflect.deleteProperty(globalThis, 'atob')
})
describe('dashboard scoped preferences', () => {
it('uses base keys when no employee token is available', () => {
expect(getScopedPreferenceKey('theme')).toBe('theme')
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
})
it('prefers scoped cookie/current user values before local storage', () => {
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const browser = installBrowser('theme=dark')
browser.store.set('theme', 'local-dark')
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
expect(readScopedPreference('theme')).toBe('dark')
})
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
const browser = installBrowser('theme=dark')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'document')
const second = installBrowser()
second.store.set('theme', 'base')
second.store.set('legacy-theme', 'legacy')
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
})
it('writes shared and legacy preference values without auth-token scoping', () => {
const browser = installBrowser()
writeScopedPreference('language', 'fr', ['old-language'])
expect(browser.store.get('language')).toBe('fr')
expect(browser.store.get('old-language')).toBe('fr')
expect(browser.cookie).toContain('language=fr')
})
})
+94
View File
@@ -0,0 +1,94 @@
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
return null
}
function decodeEmployeeId(token: string | null) {
if (!token) return null
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { sub?: string }
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
} catch {
return null
}
}
export function getScopedPreferenceKey(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}:${employeeId}` : baseKey
}
export function getScopedPreferenceCookieName(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}--${employeeId}` : baseKey
}
function readCookie(name: string) {
if (typeof document === 'undefined') return null
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith(`${name}=`))
?.slice(name.length + 1) ?? null
}
function writeCookie(name: string, value: string) {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
}
export function readCurrentUserScopedPreference(baseKey: string) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
}
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
const sharedCookie = readCookie(baseKey)
if (sharedCookie) return sharedCookie
const scopedKey = getScopedPreferenceKey(baseKey)
const candidates = [scopedKey, baseKey, ...legacyKeys]
for (const key of candidates) {
const value = window.localStorage.getItem(key)
if (value) return value
}
return null
}
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return
const scopedKey = getScopedPreferenceKey(baseKey)
const scopedCookie = getScopedPreferenceCookieName(baseKey)
writeCookie(baseKey, value)
if (scopedCookie !== baseKey) {
writeCookie(scopedCookie, value)
}
window.localStorage.setItem(scopedKey, value)
window.localStorage.setItem(baseKey, value)
for (const key of legacyKeys) {
window.localStorage.setItem(key, value)
}
}
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
function installWindow(hostname: string, protocol = 'https:') {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { location: { hostname, protocol } },
})
}
async function loadUrls(env: Record<string, string | undefined> = {}) {
vi.resetModules()
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
if (env[key] === undefined) {
delete process.env[key]
} else {
process.env[key] = env[key]
}
}
return import('./urls')
}
afterEach(() => {
Reflect.deleteProperty(globalThis, 'window')
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
delete process.env.NEXT_PUBLIC_ADMIN_URL
vi.resetModules()
})
describe('dashboard cross-app URLs', () => {
it('uses default marketplace and admin bases on the server', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('http://localhost:3000')
expect(adminUrl).toBe('http://localhost:3000/admin')
})
it('strips trailing slashes from configured absolute URLs', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
})
expect(marketplaceUrl).toBe('https://market.example.com')
expect(adminUrl).toBe('https://ops.example.com/admin')
})
it('rewrites local browser fallbacks onto the current production host', async () => {
installWindow('tenant.rentaldrivego.com', 'https:')
const { marketplaceUrl, adminUrl } = await loadUrls()
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
})
it('preserves non-URL values after normalizing their trailing slash', async () => {
const { marketplaceUrl, adminUrl } = await loadUrls({
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
NEXT_PUBLIC_ADMIN_URL: '/admin/',
})
expect(marketplaceUrl).toBe('/marketplace')
expect(adminUrl).toBe('/admin')
})
})
+18
View File
@@ -0,0 +1,18 @@
import { resolveBrowserAppUrl } from '@/lib/appUrls'
function toAppBase(url: string): string {
try {
const u = new URL(url)
return (u.origin + u.pathname).replace(/\/$/, '')
} catch {
return url.replace(/\/$/, '')
}
}
export const marketplaceUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'),
)
export const adminUrl = toAppBase(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3000/admin'),
)
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const nextServer = vi.hoisted(() => {
const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() }))
const next = vi.fn(() => ({ kind: 'next' }))
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response',
body,
status: init?.status,
})) as any
MockNextResponse.redirect = redirect
MockNextResponse.next = next
return { redirect, next, MockNextResponse }
})
vi.mock('next/server', () => ({
NextResponse: nextServer.MockNextResponse,
}))
function cloneableUrl(input: string): URL {
const url = new URL(input)
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
return url
}
function request(input: string, options: { token?: string; headers?: Record<string, string> } = {}) {
const headers = new Map(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]))
return {
nextUrl: cloneableUrl(input),
cookies: {
get: vi.fn((name: string) => (name === 'employee_session' && options.token ? { value: options.token } : undefined)),
},
headers: {
get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null),
has: vi.fn((name: string) => headers.has(name.toLowerCase())),
},
}
}
async function loadMiddleware(marketplaceUrl = 'https://market.example.com') {
vi.resetModules()
process.env.NEXT_PUBLIC_MARKETPLACE_URL = marketplaceUrl
return import('./middleware')
}
beforeEach(() => {
nextServer.redirect.mockClear()
nextServer.next.mockClear()
})
afterEach(() => {
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
vi.resetModules()
})
describe('dashboard middleware', () => {
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/dashboard?x=1') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard?x=1' })
})
it('rejects middleware subrequest headers at the app layer', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard', {
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
}) as never)
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
})
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fteam' })
})
it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
headers: {
'x-forwarded-host': 'workspace.customer.example',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
})
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
const { default: middleware } = await loadMiddleware('https://market.example.com')
const response = middleware(request('http://dashboard:3001/dashboard/fleet', {
headers: {
'x-forwarded-host': 'api:4000',
'x-forwarded-proto': 'https',
},
}) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com:3001/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet' })
})
it('redirects signed-in users away from the sign-in page', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' })
})
it('allows public dashboard auth pages without a token', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/forgot-password') as never)
expect(response).toEqual({ kind: 'next' })
expect(nextServer.next).toHaveBeenCalledTimes(1)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const HOMEPAGE_URL = process.env.NEXT_PUBLIC_HOMEPAGE_URL ?? 'http://localhost:3000'
const DASHBOARD_BASE_PATH = '/dashboard'
function toDashboardAppPath(pathname: string): string {
let normalized = pathname || '/'
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
function toPublicDashboardPath(pathname: string): string {
const appPath = toDashboardAppPath(pathname)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
function deduplicatePublicDashboardPath(pathname: string): string | null {
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
let normalized = pathname
while (normalized.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length)
}
return normalized || DASHBOARD_BASE_PATH
}
function resolveProxyUrl(req: NextRequest, pathname: string): URL {
const forwardedHost = req.headers.get('x-forwarded-host')
const forwardedProto = req.headers.get('x-forwarded-proto')
const marketplaceOrigin = new URL(MARKETPLACE_URL)
const url = new URL(pathname, req.nextUrl.origin)
if (forwardedHost && !isInternalHost(forwardedHost)) {
url.host = forwardedHost
url.protocol = (forwardedProto ?? 'http') + ':'
} else if (!isInternalHost(req.nextUrl.host)) {
url.host = req.nextUrl.host
url.protocol = req.nextUrl.protocol
} else {
url.host = marketplaceOrigin.host
url.protocol = marketplaceOrigin.protocol
}
return url
}
function isInternalHost(host: string | null): boolean {
if (!host) return false
const hostname = host.split(':')[0]?.toLowerCase()
return ['host.docker.internal', 'dashboard', 'admin', 'api', 'marketplace'].includes(hostname)
}
function isProtectedRoute(req: NextRequest) {
const pathname = toDashboardAppPath(req.nextUrl.pathname)
if (['/sign-up', '/onboarding'].some((p) => pathname === p || pathname.startsWith(p + '/'))) return false
return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
}
function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_session')?.value
if (!isProtectedRoute(req)) return NextResponse.next()
if (!token) {
const signInUrl = new URL('/sign-in', HOMEPAGE_URL)
signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
return NextResponse.redirect(signInUrl)
}
return NextResponse.next()
}
export default function middleware(req: NextRequest) {
if (req.headers.has('x-middleware-subrequest')) {
return new NextResponse('Unsupported internal request header', { status: 400 })
}
const dedupedPath = deduplicatePublicDashboardPath(req.nextUrl.pathname)
if (dedupedPath) {
const redirectUrl = req.nextUrl.clone()
redirectUrl.pathname = dedupedPath
return NextResponse.redirect(redirectUrl)
}
return localJwtMiddleware(req)
}
export const config = {
matcher: [
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
}
+27
View File
@@ -0,0 +1,27 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
describe('dashboard next config', () => {
it('allows generated static media assets to be loaded across local app origins', async () => {
const nextConfig = require('../next.config.js')
const headers = await nextConfig.headers()
expect(headers).toEqual(
expect.arrayContaining([
{
source: '/_next/static/media/:path*',
headers: expect.arrayContaining([
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
]),
},
]),
)
})
})
+162
View File
@@ -0,0 +1,162 @@
/* RentalDriveGo Phase 16 design tokens.
* Kept deliberately identical across the homepage, operator dashboard, and
* platform admin so the product behaves like one system rather than three
* unrelated Tailwind experiments.
*/
:root {
--rdg-blue-50: #eff6ff;
--rdg-blue-100: #dbeafe;
--rdg-blue-200: #bfdbfe;
--rdg-blue-500: #2874e8;
--rdg-blue-600: #1b5dd8;
--rdg-blue-700: #174bb5;
--rdg-blue-800: #183d88;
--rdg-orange-100: #ffedd5;
--rdg-orange-400: #fb923c;
--rdg-orange-600: #ea580c;
--rdg-orange-700: #c2410c;
--rdg-navy-950: #081426;
--rdg-navy-900: #0d1b2e;
--rdg-navy-850: #11233b;
--rdg-navy-800: #162b46;
--rdg-gray-25: #fbfdff;
--rdg-gray-50: #f7f9fc;
--rdg-gray-100: #eef2f7;
--rdg-gray-200: #dbe3ed;
--rdg-gray-300: #c6d0dc;
--rdg-gray-500: #64748b;
--rdg-gray-600: #475569;
--rdg-gray-700: #334155;
--rdg-gray-800: #1e293b;
--rdg-gray-900: #0f172a;
--rdg-green-100: #dcfce7;
--rdg-green-700: #15803d;
--rdg-amber-100: #fef3c7;
--rdg-amber-800: #92400e;
--rdg-red-100: #fee2e2;
--rdg-red-700: #b91c1c;
--rdg-space-1: 4px;
--rdg-space-2: 8px;
--rdg-space-3: 12px;
--rdg-space-4: 16px;
--rdg-space-5: 20px;
--rdg-space-6: 24px;
--rdg-space-8: 32px;
--rdg-space-10: 40px;
--rdg-space-12: 48px;
--rdg-space-16: 64px;
--rdg-space-20: 80px;
--rdg-space-24: 96px;
--rdg-radius-sm: 10px;
--rdg-radius-md: 16px;
--rdg-radius-lg: 24px;
--rdg-radius-xl: 32px;
--rdg-radius-pill: 999px;
--rdg-touch-min: 44px;
--rdg-header-size: 76px;
--rdg-sidebar-size: 272px;
--rdg-container-max: 1440px;
--rdg-content-max: 1280px;
--rdg-duration-fast: 120ms;
--rdg-duration-standard: 160ms;
--rdg-duration-overlay: 220ms;
--rdg-easing: cubic-bezier(0.2, 0, 0, 1);
--rdg-font-latin: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--rdg-font-arabic: "Noto Sans Arabic", Tahoma, Arial, sans-serif;
--rdg-surface-page: var(--rdg-gray-25);
--rdg-surface-primary: #ffffff;
--rdg-surface-elevated: #ffffff;
--rdg-surface-muted: #f2f6fb;
--rdg-surface-strong: #e8f0fa;
--rdg-text-primary: #102035;
--rdg-text-secondary: #52647a;
--rdg-text-subdued: var(--rdg-gray-500);
--rdg-text-inverse: #f8fafc;
--rdg-border: #d8e2ee;
--rdg-border-strong: #bdcad9;
--rdg-action-primary: var(--rdg-blue-700);
--rdg-action-primary-hover: var(--rdg-blue-800);
--rdg-action-conversion: var(--rdg-orange-700);
--rdg-action-conversion-hover: #9a3412;
--rdg-focus: #f97316;
--rdg-soft-blue: #eef5ff;
--rdg-soft-orange: #fff7ed;
--rdg-overlay: rgba(8, 20, 38, 0.62);
--rdg-shadow-color: rgba(8, 20, 38, 0.12);
--rdg-shadow-subtle: 0 1px 2px var(--rdg-shadow-color);
--rdg-shadow-card: 0 20px 60px var(--rdg-shadow-color);
--rdg-shadow-overlay: 0 28px 80px var(--rdg-shadow-color);
/* Compatibility aliases retained for existing pages. */
--primary: var(--rdg-action-primary);
--primary-hover: var(--rdg-action-primary-hover);
--accent: var(--rdg-action-conversion);
--bg-elevated: var(--rdg-surface-elevated);
--border-accent: var(--rdg-border);
--glass-bg: color-mix(in srgb, var(--rdg-surface-elevated) 90%, transparent);
--shadow-card: var(--rdg-shadow-card);
--shadow-glow: 0 0 20px color-mix(in srgb, var(--rdg-action-primary) 16%, transparent);
}
html.dark,
html[data-theme='dark'] {
--rdg-surface-page: var(--rdg-navy-950);
--rdg-surface-primary: var(--rdg-navy-900);
--rdg-surface-elevated: var(--rdg-navy-850);
--rdg-surface-muted: #0b1a2d;
--rdg-surface-strong: #172d48;
--rdg-text-primary: #eef5ff;
--rdg-text-secondary: #adbed2;
--rdg-text-subdued: var(--rdg-gray-300);
--rdg-text-inverse: var(--rdg-navy-950);
--rdg-border: #263c56;
--rdg-border-strong: #3a536e;
--rdg-action-primary: #76a9ff;
--rdg-action-primary-hover: #9bc2ff;
--rdg-action-conversion: var(--rdg-orange-400);
--rdg-action-conversion-hover: #fdba74;
--rdg-focus: var(--rdg-orange-400);
--rdg-soft-blue: #10284a;
--rdg-soft-orange: #3a2015;
--rdg-overlay: rgba(2, 8, 18, 0.76);
--rdg-shadow-color: rgba(0, 0, 0, 0.36);
color-scheme: dark;
}
html.light,
html[data-theme='light'] {
color-scheme: light;
}
html[lang='ar'] body {
font-family: var(--rdg-font-arabic);
line-height: 1.8;
}
::selection {
background: var(--rdg-blue-200);
color: var(--rdg-gray-900);
}
html.dark ::selection,
html[data-theme='dark'] ::selection {
background: var(--rdg-blue-800);
color: var(--rdg-gray-25);
}
:focus-visible {
outline: 3px solid var(--rdg-focus);
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss'
const config: Config = {
content: ['./src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
},
},
fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
},
},
plugins: [],
}
export default config
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+17
View File
@@ -0,0 +1,17 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'node',
globals: true,
include: ['src/**/*.test.ts'],
clearMocks: true,
restoreMocks: true,
},
})