diff --git a/apps/admin/src/app/dashboard/companies/[id]/page.tsx b/apps/admin/src/app/dashboard/companies/[id]/page.tsx index 5ec0c94..d0ee90a 100644 --- a/apps/admin/src/app/dashboard/companies/[id]/page.tsx +++ b/apps/admin/src/app/dashboard/companies/[id]/page.tsx @@ -5,12 +5,38 @@ import { useParams, useRouter } from 'next/navigation' import Link from 'next/link' import { ADMIN_API_BASE } from '@/lib/api' +interface CompanyAddressProfile { + streetAddress?: string | null + city?: string | null + country?: string | null + zipCode?: string | null + legalName?: string | null + legalForm?: string | null + companyEmail?: string | null + managerName?: string | null + iceNumber?: string | null + operatingLicenseNumber?: string | null + operatingLicenseIssuedAt?: string | null + operatingLicenseIssuedBy?: string | null + fax?: string | null + yearsActive?: string | null + representativeName?: string | null + representativeTitle?: string | null + responsibleName?: string | null + responsibleRole?: string | null + responsibleIdentityNumber?: string | null + responsibleQualification?: string | null + responsiblePhone?: string | null + responsibleEmail?: string | null +} + interface CompanyDetail { id: string name: string slug: string email: string phone: string | null + address: CompanyAddressProfile | string | null status: string subscriptionPaymentRef: string | null createdAt: string @@ -100,6 +126,25 @@ interface FormState { status: string subscriptionPaymentRef: string } + companyProfile: { + legalForm: string + managerName: string + zipCode: string + fax: string + yearsActive: string + iceNumber: string + operatingLicenseNumber: string + operatingLicenseIssuedAt: string + operatingLicenseIssuedBy: string + representativeName: string + representativeTitle: string + responsibleName: string + responsibleRole: string + responsibleIdentityNumber: string + responsibleQualification: string + responsiblePhone: string + responsibleEmail: string + } subscription: { plan: string billingPeriod: string @@ -175,7 +220,13 @@ function toNullable(value: string) { return trimmed ? trimmed : null } +function normalizeAddressProfile(value: CompanyDetail['address']): CompanyAddressProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value +} + function createFormState(company: CompanyDetail): FormState { + const address = normalizeAddressProfile(company.address) return { company: { name: company.name ?? '', @@ -185,6 +236,25 @@ function createFormState(company: CompanyDetail): FormState { status: company.status ?? 'TRIALING', subscriptionPaymentRef: company.subscriptionPaymentRef ?? '', }, + companyProfile: { + legalForm: address.legalForm ?? '', + managerName: address.managerName ?? '', + zipCode: address.zipCode ?? '', + fax: address.fax ?? '', + yearsActive: address.yearsActive ?? '', + iceNumber: address.iceNumber ?? '', + operatingLicenseNumber: address.operatingLicenseNumber ?? '', + operatingLicenseIssuedAt: address.operatingLicenseIssuedAt ?? '', + operatingLicenseIssuedBy: address.operatingLicenseIssuedBy ?? '', + representativeName: address.representativeName ?? '', + representativeTitle: address.representativeTitle ?? '', + responsibleName: address.responsibleName ?? '', + responsibleRole: address.responsibleRole ?? '', + responsibleIdentityNumber: address.responsibleIdentityNumber ?? '', + responsibleQualification: address.responsibleQualification ?? '', + responsiblePhone: address.responsiblePhone ?? '', + responsibleEmail: address.responsibleEmail ?? '', + }, subscription: { plan: company.subscription?.plan ?? 'STARTER', billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY', @@ -303,6 +373,30 @@ export default function AdminCompanyDetailPage() { email: form.company.email, phone: toNullable(form.company.phone), subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef), + address: { + streetAddress: toNullable(form.brand.publicAddress), + city: toNullable(form.brand.publicCity), + country: toNullable(form.brand.publicCountry), + zipCode: toNullable(form.companyProfile.zipCode), + legalName: toNullable(form.contractSettings.legalName), + legalForm: toNullable(form.companyProfile.legalForm), + companyEmail: toNullable(form.company.email), + managerName: toNullable(form.companyProfile.managerName), + iceNumber: toNullable(form.companyProfile.iceNumber), + operatingLicenseNumber: toNullable(form.companyProfile.operatingLicenseNumber), + operatingLicenseIssuedAt: toNullable(form.companyProfile.operatingLicenseIssuedAt), + operatingLicenseIssuedBy: toNullable(form.companyProfile.operatingLicenseIssuedBy), + fax: toNullable(form.companyProfile.fax), + yearsActive: toNullable(form.companyProfile.yearsActive), + representativeName: toNullable(form.companyProfile.representativeName), + representativeTitle: toNullable(form.companyProfile.representativeTitle), + responsibleName: toNullable(form.companyProfile.responsibleName), + responsibleRole: toNullable(form.companyProfile.responsibleRole), + responsibleIdentityNumber: toNullable(form.companyProfile.responsibleIdentityNumber), + responsibleQualification: toNullable(form.companyProfile.responsibleQualification), + responsiblePhone: toNullable(form.companyProfile.responsiblePhone), + responsibleEmail: toNullable(form.companyProfile.responsibleEmail), + }, }, subscription: { plan: form.subscription.plan, @@ -637,6 +731,94 @@ export default function AdminCompanyDetailPage() { +
+

Company legal profile

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

Legal representative

+
+ + +
+
+ +
+

Responsible person

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

Operations and finance

diff --git a/apps/admin/src/app/dashboard/companies/page.tsx b/apps/admin/src/app/dashboard/companies/page.tsx index b62250b..75d85b1 100644 --- a/apps/admin/src/app/dashboard/companies/page.tsx +++ b/apps/admin/src/app/dashboard/companies/page.tsx @@ -11,6 +11,7 @@ interface Company { slug: string status: string email: string + contractSettings: { legalName: string | null } | null subscription: { plan: string; status: string } | null _count: { employees: number; vehicles: number } } @@ -32,6 +33,7 @@ export default function AdminCompaniesPage() { loading: 'Loading…', empty: 'No companies found', company: 'Company', + legalName: 'Legal name', slug: 'Slug', status: 'Status', plan: 'Plan', @@ -47,6 +49,7 @@ export default function AdminCompaniesPage() { loading: 'Chargement…', empty: 'Aucune entreprise trouvée', company: 'Entreprise', + legalName: 'Raison sociale', slug: 'Slug', status: 'Statut', plan: 'Plan', @@ -62,6 +65,7 @@ export default function AdminCompaniesPage() { loading: 'جارٍ التحميل…', empty: 'لم يتم العثور على شركات', company: 'الشركة', + legalName: 'الاسم القانوني', slug: 'Slug', status: 'الحالة', plan: 'الخطة', @@ -162,6 +166,9 @@ export default function AdminCompaniesPage() {

{c.name}

+ {c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? ( +

{copy.legalName}: {c.contractSettings.legalName}

+ ) : null}

{c.email}

{c.slug} diff --git a/apps/api/src/modules/admin/admin.repo.ts b/apps/api/src/modules/admin/admin.repo.ts index ebcbd14..2b5dbea 100644 --- a/apps/api/src/modules/admin/admin.repo.ts +++ b/apps/api/src/modules/admin/admin.repo.ts @@ -2,6 +2,7 @@ import { prisma } from '../../lib/prisma' const companyListInclude = { brand: { select: { displayName: true, logoUrl: true, subdomain: true } }, + contractSettings: { select: { legalName: true } }, subscription: { select: { plan: true, status: true } }, _count: { select: { employees: true, vehicles: true } }, } as const @@ -125,9 +126,27 @@ export function getCompanyUpdateSnapshot(id: string) { }) } -export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) { +export async function applyCompanyUpdate( + id: string, + body: any, + current: { + name: string + slug: string + address?: unknown + brand?: { paymentMethodsEnabled?: any[] | null } | null + }, +) { return prisma.$transaction(async (tx) => { - if (body.company) await tx.company.update({ where: { id }, data: body.company }) + if (body.company) { + const companyData = { ...body.company } + if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) { + const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address) + ? current.address as Record + : {} + companyData.address = { ...baseAddress, ...companyData.address } + } + await tx.company.update({ where: { id }, data: companyData }) + } if (body.subscription) { const sub = body.subscription diff --git a/apps/api/src/modules/admin/admin.schemas.ts b/apps/api/src/modules/admin/admin.schemas.ts index 0a4c02d..9777895 100644 --- a/apps/api/src/modules/admin/admin.schemas.ts +++ b/apps/api/src/modules/admin/admin.schemas.ts @@ -112,6 +112,30 @@ export const adminCompanyUpdateSchema = z.object({ email: z.string().email().optional(), phone: nullableString, status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(), subscriptionPaymentRef: nullableString, + address: z.object({ + streetAddress: nullableString, + city: nullableString, + country: nullableString, + zipCode: nullableString, + legalName: nullableString, + legalForm: nullableString, + companyEmail: nullableEmail, + managerName: nullableString, + iceNumber: nullableString, + operatingLicenseNumber: nullableString, + operatingLicenseIssuedAt: nullableString, + operatingLicenseIssuedBy: nullableString, + fax: nullableString, + yearsActive: nullableString, + representativeName: nullableString, + representativeTitle: nullableString, + responsibleName: nullableString, + responsibleRole: nullableString, + responsibleIdentityNumber: nullableString, + responsibleQualification: nullableString, + responsiblePhone: nullableString, + responsibleEmail: nullableEmail, + }).optional(), }).optional(), subscription: z.object({ plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(), diff --git a/apps/api/src/modules/marketplace/marketplace.repo.ts b/apps/api/src/modules/marketplace/marketplace.repo.ts index be7ee6c..bd56e64 100644 --- a/apps/api/src/modules/marketplace/marketplace.repo.ts +++ b/apps/api/src/modules/marketplace/marketplace.repo.ts @@ -49,13 +49,59 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug: } export async function upsertMarketplaceCustomer(companyId: string, data: { - email: string; firstName: string; lastName: string; phone?: string + email: string + firstName: string + lastName: string + phone?: string + dateOfBirth?: string + nationality?: string + identityDocumentNumber?: string + fullAddress?: string + driverLicense?: string + licenseExpiry?: string + licenseIssuedAt?: string + licenseCountry?: string + licenseCategory?: string + internationalLicenseNumber?: string }) { - return prisma.customer.upsert({ + const existing = await prisma.customer.findUnique({ where: { companyId_email: { companyId, email: data.email } }, - create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone }, - update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) }, + select: { id: true, address: true }, }) + + // Only patch the address JSON with fields that were actually provided + const prevAddress = (existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)) + ? existing.address as Record + : {} + const hasAddressPatch = data.fullAddress || data.identityDocumentNumber || data.internationalLicenseNumber !== undefined + const address = hasAddressPatch + ? { + ...prevAddress, + ...(data.fullAddress ? { fullAddress: data.fullAddress } : {}), + ...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}), + ...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}), + } + : undefined + + const payload = { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + ...(data.phone ? { phone: data.phone } : {}), + ...(data.driverLicense ? { driverLicense: data.driverLicense, licenseNumber: data.driverLicense } : {}), + ...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}), + ...(data.nationality ? { nationality: data.nationality } : {}), + ...(address !== undefined ? { address } : {}), + ...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}), + ...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}), + ...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}), + ...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}), + } + + if (!existing) { + return prisma.customer.create({ data: { companyId, ...payload } }) + } + return prisma.customer.update({ where: { id: existing.id }, data: payload }) } export async function createMarketplaceReservation(data: { diff --git a/apps/api/src/modules/marketplace/marketplace.schemas.ts b/apps/api/src/modules/marketplace/marketplace.schemas.ts index a958f46..4cb52ac 100644 --- a/apps/api/src/modules/marketplace/marketplace.schemas.ts +++ b/apps/api/src/modules/marketplace/marketplace.schemas.ts @@ -30,7 +30,19 @@ export const marketplaceReservationSchema = z.object({ firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), email: z.string().email(), - phone: z.string().max(30).optional(), + // Contact info — collected at reservation time + phone: z.string().min(1).max(30).optional(), + // Identity & license — optional at reservation, collected at pickup + dateOfBirth: z.string().datetime().optional(), + nationality: z.string().min(1).max(100).optional(), + identityDocumentNumber: z.string().min(1).max(100).optional(), + fullAddress: z.string().min(1).max(500).optional(), + driverLicense: z.string().min(1).max(50).optional(), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + licenseCountry: z.string().min(1).max(100).optional(), + licenseCategory: z.string().min(1).max(20).optional(), + internationalLicenseNumber: z.string().trim().max(100).optional(), startDate: z.string().datetime(), endDate: z.string().datetime(), pickupLocation: z.string().trim().max(100).optional(), diff --git a/apps/api/src/modules/marketplace/marketplace.service.ts b/apps/api/src/modules/marketplace/marketplace.service.ts index 5cc6b7a..5f949d4 100644 --- a/apps/api/src/modules/marketplace/marketplace.service.ts +++ b/apps/api/src/modules/marketplace/marketplace.service.ts @@ -121,7 +121,11 @@ export async function searchVehicles(params: { export async function createMarketplaceReservation(body: { vehicleId: string; companySlug: string; firstName: string; lastName: string - email: string; phone?: string; startDate: string; endDate: string + email: string; phone?: string; dateOfBirth?: string; nationality?: string + identityDocumentNumber?: string; fullAddress?: string + driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string + licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string + startDate: string; endDate: string pickupLocation?: string; returnLocation?: string; notes?: string; language?: string }) { const startDate = new Date(body.startDate) @@ -160,7 +164,20 @@ export async function createMarketplaceReservation(body: { } const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, { - email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone, + email: body.email, + firstName: body.firstName, + lastName: body.lastName, + phone: body.phone, + dateOfBirth: body.dateOfBirth, + nationality: body.nationality, + identityDocumentNumber: body.identityDocumentNumber, + fullAddress: body.fullAddress, + driverLicense: body.driverLicense, + licenseExpiry: body.licenseExpiry, + licenseIssuedAt: body.licenseIssuedAt, + licenseCountry: body.licenseCountry, + licenseCategory: body.licenseCategory, + internationalLicenseNumber: body.internationalLicenseNumber, }) const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000)) diff --git a/apps/api/src/modules/marketplace/marketplace.test.ts b/apps/api/src/modules/marketplace/marketplace.test.ts index f707eb5..7e2010e 100644 --- a/apps/api/src/modules/marketplace/marketplace.test.ts +++ b/apps/api/src/modules/marketplace/marketplace.test.ts @@ -121,6 +121,16 @@ describe('createMarketplaceReservation', () => { const baseBody = { vehicleId: 'v-1', companySlug: SLUG, firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', + phone: '+212600000000', + dateOfBirth: '1990-01-01T00:00:00.000Z', + nationality: 'Moroccan', + identityDocumentNumber: 'CIN123456', + fullAddress: '123 Avenue Hassan II, Casablanca', + driverLicense: 'DL-123456', + licenseExpiry: '2030-01-01T00:00:00.000Z', + licenseIssuedAt: '2015-01-01T00:00:00.000Z', + licenseCountry: 'Morocco', + licenseCategory: 'B', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', } @@ -132,6 +142,11 @@ describe('createMarketplaceReservation', () => { const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' }) expect(result.reservationId).toBe('r-1') + expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({ + identityDocumentNumber: 'CIN123456', + fullAddress: '123 Avenue Hassan II, Casablanca', + driverLicense: 'DL-123456', + })) expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({ pickupLocation: 'Casablanca', returnLocation: 'Casablanca', diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts index aaf772d..0424a82 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts @@ -8,6 +8,17 @@ import { coerceNotificationLocale } from '../../services/notificationLocalizatio import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' import * as repo from './reservation.repo' +function buildPickupAddress(value: unknown) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return '' + + const address = value as Record + return [ + typeof address.streetAddress === 'string' ? address.streetAddress.trim() : '', + typeof address.city === 'string' ? address.city.trim() : '', + typeof address.country === 'string' ? address.country.trim() : '', + ].filter(Boolean).join(', ') +} + async function assertLicenseCompliance(reservationId: string, companyId: string) { const reservation = await repo.findForLicenseCheck(reservationId, companyId) @@ -46,6 +57,7 @@ export async function confirmReservation(id: string, companyId: string) { repo.findVehicle(reservation.vehicleId, companyId), ]) const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale) + const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address) await sendNotification({ type: 'BOOKING_CONFIRMED', companyId, @@ -57,8 +69,29 @@ export async function confirmReservation(id: string, companyId: string) { templateVariables: { firstName: customer.firstName, companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo', - startDate: reservation.startDate, - endDate: reservation.endDate, + startDate: { + type: 'date', + value: reservation.startDate, + options: { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }, + }, + endDate: { + type: 'date', + value: reservation.endDate, + options: { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }, + }, + pickupLocation, vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`, }, }).catch(() => null) diff --git a/apps/api/src/modules/reservations/reservation.test.ts b/apps/api/src/modules/reservations/reservation.test.ts index 6bd1004..5df5ed4 100644 --- a/apps/api/src/modules/reservations/reservation.test.ts +++ b/apps/api/src/modules/reservations/reservation.test.ts @@ -72,6 +72,7 @@ import * as pricingService from './reservation.pricing.service' import * as insuranceService from './reservation.insurance.service' import * as additionalDriverService from './reservation.additional-driver.service' import { validateLicense } from '../../services/licenseValidationService' +import { sendNotification } from '../../services/notificationService' import { buildReservationWorkflow } from './reservation.presenter' import { createReservation } from './reservation.service' import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service' @@ -158,7 +159,7 @@ describe('createReservation', () => { // ──────────────────────────────────────────────────────────────────────────── describe('confirmReservation', () => { it('confirms a DRAFT reservation', async () => { - const res = makeReservation({ status: 'DRAFT' }) + const res = makeReservation({ status: 'DRAFT', pickupLocation: 'Casablanca Airport Terminal 1' }) vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any) vi.mocked(repo.findForLicenseCheck).mockResolvedValue({ ...res, @@ -174,6 +175,20 @@ describe('confirmReservation', () => { const result = await confirmReservation(RES_ID, COMPANY) expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' }) + expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ + templateKey: 'booking.confirmed', + templateVariables: expect.objectContaining({ + pickupLocation: 'Casablanca Airport Terminal 1', + startDate: expect.objectContaining({ + type: 'date', + value: res.startDate, + }), + endDate: expect.objectContaining({ + type: 'date', + value: res.endDate, + }), + }), + })) expect((result as any).status).toBe('CONFIRMED') }) diff --git a/apps/api/src/modules/site/site.repo.ts b/apps/api/src/modules/site/site.repo.ts index af990ec..0bb59d4 100644 --- a/apps/api/src/modules/site/site.repo.ts +++ b/apps/api/src/modules/site/site.repo.ts @@ -41,19 +41,61 @@ export async function findOfferByPromoCode(companyId: string, code: string) { } export async function upsertCustomer(companyId: string, data: { - email: string; firstName: string; lastName: string; phone?: string | null - driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null - licenseIssuedAt?: string | null; nationality?: string | null + email: string + firstName: string + lastName: string + phone: string + driverLicense: string + dateOfBirth: string + licenseExpiry: string + licenseIssuedAt: string + nationality: string + identityDocumentNumber: string + fullAddress: string + licenseCountry: string + licenseNumber?: string | null + licenseCategory: string + internationalLicenseNumber?: string | null }) { - const parsed = { - dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null, - licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null, - licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null, - } - return prisma.customer.upsert({ + const existing = await prisma.customer.findUnique({ where: { companyId_email: { companyId, email: data.email } }, - create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed }, - update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed }, + select: { id: true, address: true }, + }) + + const address = { + ...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address) + ? existing.address as Record + : {}), + fullAddress: data.fullAddress, + identityDocumentNumber: data.identityDocumentNumber, + internationalLicenseNumber: data.internationalLicenseNumber ?? null, + } + + const payload = { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + phone: data.phone, + driverLicense: data.driverLicense, + dateOfBirth: new Date(data.dateOfBirth), + nationality: data.nationality, + address, + licenseExpiry: new Date(data.licenseExpiry), + licenseIssuedAt: new Date(data.licenseIssuedAt), + licenseCountry: data.licenseCountry, + licenseNumber: data.licenseNumber ?? data.driverLicense, + licenseCategory: data.licenseCategory, + } + + if (!existing) { + return prisma.customer.create({ + data: { companyId, ...payload }, + }) + } + + return prisma.customer.update({ + where: { id: existing.id }, + data: payload, }) } diff --git a/apps/api/src/modules/site/site.schemas.ts b/apps/api/src/modules/site/site.schemas.ts index 9a02ac8..bd4ae8d 100644 --- a/apps/api/src/modules/site/site.schemas.ts +++ b/apps/api/src/modules/site/site.schemas.ts @@ -18,12 +18,18 @@ export const bookSchema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), - phone: z.string().optional(), - driverLicense: z.string().optional(), - dateOfBirth: z.string().datetime().optional(), - licenseExpiry: z.string().datetime().optional(), - licenseIssuedAt: z.string().datetime().optional(), - nationality: z.string().optional(), + phone: z.string().min(1).max(30), + driverLicense: z.string().min(1).max(50), + dateOfBirth: z.string().datetime(), + licenseExpiry: z.string().datetime(), + licenseIssuedAt: z.string().datetime(), + nationality: z.string().min(1).max(100), + identityDocumentNumber: z.string().min(1).max(100), + fullAddress: z.string().min(1).max(500), + licenseCountry: z.string().min(1).max(100), + licenseNumber: z.string().min(1).max(50).optional(), + licenseCategory: z.string().min(1).max(20), + internationalLicenseNumber: z.string().trim().max(100).optional(), offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), notes: z.string().optional(), diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts index 17bb6c4..aa39b49 100644 --- a/apps/api/src/modules/site/site.service.ts +++ b/apps/api/src/modules/site/site.service.ts @@ -93,8 +93,9 @@ export async function validatePromoCode(slug: string, code: string) { export async function createBooking(slug: string, body: { vehicleId: string; startDate: string; endDate: string - firstName: string; lastName: string; email: string; phone?: string - driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string + firstName: string; lastName: string; email: string; phone: string + driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string + identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string offerId?: string; promoCodeUsed?: string; notes?: string; source?: string selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[] }) { @@ -113,9 +114,21 @@ export async function createBooking(slug: string, body: { } const customer = await repo.upsertCustomer(company.id, { - email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone, - driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth, - licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality, + email: body.email, + firstName: body.firstName, + lastName: body.lastName, + phone: body.phone, + driverLicense: body.driverLicense, + dateOfBirth: body.dateOfBirth, + licenseExpiry: body.licenseExpiry, + licenseIssuedAt: body.licenseIssuedAt, + nationality: body.nationality, + identityDocumentNumber: body.identityDocumentNumber, + fullAddress: body.fullAddress, + licenseCountry: body.licenseCountry, + licenseNumber: body.licenseNumber, + licenseCategory: body.licenseCategory, + internationalLicenseNumber: body.internationalLicenseNumber, }) const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000)) diff --git a/apps/dashboard/src/app/(dashboard)/reservations/new/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/new/page.tsx index 7766a0b..4a4e4d3 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/new/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/new/page.tsx @@ -13,9 +13,13 @@ type Customer = { email: string phone?: string | null driverLicense?: string | null + dateOfBirth?: string | null + nationality?: string | null + address?: Record | null licenseExpiry?: string | null licenseIssuedAt?: string | null licenseCountry?: string | null + licenseNumber?: string | null licenseCategory?: string | null licenseImageUrl?: string | null } @@ -62,6 +66,11 @@ export default function NewReservationPage() { 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('') @@ -94,12 +103,16 @@ export default function NewReservationPage() { 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', @@ -108,7 +121,7 @@ export default function NewReservationPage() { addAdditionalDriver: 'Add additional driver', additionalDriverInfo: 'Additional driver', dateOfBirth: 'Date of birth', - nationality: 'License country', + nationality: 'Nationality', notes: 'Notes', notesPlaceholder: 'Optional notes…', create: 'Create booking', @@ -148,12 +161,16 @@ export default function NewReservationPage() { 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é', @@ -162,7 +179,7 @@ export default function NewReservationPage() { addAdditionalDriver: 'Ajouter un conducteur supplémentaire', additionalDriverInfo: 'Conducteur supplémentaire', dateOfBirth: 'Date de naissance', - nationality: 'Pays du permis', + nationality: 'Nationalité', notes: 'Notes', notesPlaceholder: 'Notes optionnelles…', create: 'Créer la réservation', @@ -202,12 +219,16 @@ export default function NewReservationPage() { return: 'موقع التسليم', deposit: 'مبلغ العربون (MAD)', paymentMode: 'طريقة الدفع', + renterIdentity: 'بيانات المستأجر', driverLicenseInfo: 'معلومات رخصة القيادة', driverLicense: 'رقم رخصة القيادة', licenseExpiry: 'تاريخ انتهاء الرخصة', licenseIssuedAt: 'تاريخ إصدار الرخصة', licenseCountry: 'بلد الرخصة', licenseCategory: 'فئة الرخصة', + fullAddress: 'العنوان الكامل', + identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر', + internationalLicenseNumber: 'رقم الرخصة الدولية', licenseImage: 'صورة رخصة القيادة', licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.', licenseImageSelected: 'الملف المحدد', @@ -216,7 +237,7 @@ export default function NewReservationPage() { addAdditionalDriver: 'إضافة سائق إضافي', additionalDriverInfo: 'السائق الإضافي', dateOfBirth: 'تاريخ الميلاد', - nationality: 'بلد الرخصة', + nationality: 'الجنسية', notes: 'ملاحظات', notesPlaceholder: 'ملاحظات اختيارية…', create: 'إنشاء الحجز', @@ -272,10 +293,22 @@ export default function NewReservationPage() { 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 ?? '') + 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 ?? '') @@ -318,6 +351,20 @@ export default function NewReservationPage() { 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('/customers', { @@ -329,11 +376,19 @@ export default function NewReservationPage() { lastNameAr: newCustomerLastName.ar.trim() || undefined, email: newCustomerEmail.trim(), phone: newCustomerPhone.trim(), - driverLicense: driverLicense.trim() || undefined, - licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined, - licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined, - licenseCountry: licenseCountry.trim() || undefined, - licenseCategory: licenseCategory.trim() || undefined, + 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]) @@ -368,6 +423,20 @@ export default function NewReservationPage() { 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 @@ -378,11 +447,19 @@ export default function NewReservationPage() { await apiFetch(`/customers/${customerId}`, { method: 'PATCH', body: JSON.stringify({ - driverLicense: driverLicense.trim() || undefined, - licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined, - licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined, - licenseCountry: licenseCountry.trim() || undefined, - licenseCategory: licenseCategory.trim() || undefined, + 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(), }), }) @@ -509,29 +586,57 @@ export default function NewReservationPage() {
+
+

{copy.renterIdentity}

+
+ + +
+
+ + +
+