diff --git a/apps/api/src/middleware/rateLimiter.ts b/apps/api/src/middleware/rateLimiter.ts index edadaa8..6224a72 100644 --- a/apps/api/src/middleware/rateLimiter.ts +++ b/apps/api/src/middleware/rateLimiter.ts @@ -4,13 +4,15 @@ import type { Request } from 'express' // req.ip is already the real client IP when app.set('trust proxy', 1) is configured const getClientIpKey = (req: Request) => ipKeyGenerator(req.ip ?? '') -// Strict limiter for auth endpoints — prevents brute-force and credential stuffing +// Strict limiter for auth endpoints — prevents brute-force and credential stuffing. +// Successful requests (e.g. GET /me profile reads) are skipped so only failed +// attempts count toward the cap. export const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, standardHeaders: 'draft-7', legacyHeaders: false, - skipSuccessfulRequests: false, + skipSuccessfulRequests: true, keyGenerator: (req) => getClientIpKey(req), message: { error: 'too_many_requests', message: 'Too many attempts, please try again later', statusCode: 429 }, }) diff --git a/apps/api/src/routes/offers.ts b/apps/api/src/routes/offers.ts index 19c0df1..5c03615 100644 --- a/apps/api/src/routes/offers.ts +++ b/apps/api/src/routes/offers.ts @@ -61,8 +61,17 @@ router.get('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => { try { const { vehicleIds, ...body } = offerSchema.partial().parse(req.body) - const offer = await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } }) - if (offer.count === 0) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 }) + const existing = await prisma.offer.findFirst({ where: { id: req.params.id, companyId: req.companyId } }) + if (!existing) return res.status(404).json({ error: 'not_found', message: 'Offer not found', statusCode: 404 }) + await prisma.$transaction(async (tx) => { + await tx.offer.update({ where: { id: req.params.id }, data: { ...body, validFrom: body.validFrom ? new Date(body.validFrom) : undefined, validUntil: body.validUntil ? new Date(body.validUntil) : undefined } }) + if (vehicleIds !== undefined) { + await tx.offerVehicle.deleteMany({ where: { offerId: req.params.id } }) + if (vehicleIds.length > 0) { + await tx.offerVehicle.createMany({ data: vehicleIds.map((vehicleId) => ({ offerId: req.params.id, vehicleId })) }) + } + } + }) const updated = await prisma.offer.findUniqueOrThrow({ where: { id: req.params.id }, include: { vehicles: true } }) res.json({ data: updated }) } catch (err) { next(err) } diff --git a/apps/api/src/routes/payments.ts b/apps/api/src/routes/payments.ts index 57316a1..e83803a 100644 --- a/apps/api/src/routes/payments.ts +++ b/apps/api/src/routes/payments.ts @@ -99,6 +99,25 @@ const chargeSchema = z.object({ failureUrl: z.string().url(), }) +router.get('/company', async (req, res, next) => { + try { + const payments = await prisma.rentalPayment.findMany({ + where: { companyId: req.companyId }, + include: { + reservation: { + include: { + customer: true, + vehicle: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }) + res.json({ data: payments }) + } catch (err) { next(err) } +}) + router.get('/reservations/:id', async (req, res, next) => { try { const payments = await prisma.rentalPayment.findMany({ diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts index 62495a3..08e91ee 100644 --- a/apps/api/src/routes/reservations.ts +++ b/apps/api/src/routes/reservations.ts @@ -56,11 +56,122 @@ const createSchema = z.object({ offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), depositAmount: z.number().int().min(0).default(0), + paymentMode: z.string().max(50).optional(), notes: z.string().optional(), selectedInsurancePolicyIds: z.array(z.string()).default([]), additionalDrivers: z.array(additionalDriverSchema).default([]), }) +function formatDocumentNumber(prefix: string, sequence: number) { + return `${prefix}-${String(sequence).padStart(6, '0')}` +} + +async function ensureReservationDocumentNumbers(companyId: string, reservationId: string) { + return prisma.$transaction(async (tx) => { + const reservation = await tx.reservation.findFirstOrThrow({ + where: { id: reservationId, companyId }, + select: { id: true, contractNumber: true, invoiceNumber: true }, + }) + + if (reservation.contractNumber && reservation.invoiceNumber) return reservation + + const settings = await tx.contractSettings.upsert({ + where: { companyId }, + update: {}, + create: { companyId }, + }) + + const data: { contractNumber?: string; invoiceNumber?: string } = {} + const settingsUpdate: { lastContractSeq?: number; lastInvoiceSeq?: number } = {} + + if (!reservation.contractNumber) { + const nextContractSeq = settings.lastContractSeq + 1 + data.contractNumber = formatDocumentNumber(settings.contractPrefix, nextContractSeq) + settingsUpdate.lastContractSeq = nextContractSeq + } + + if (!reservation.invoiceNumber) { + const nextInvoiceSeq = settings.lastInvoiceSeq + 1 + data.invoiceNumber = formatDocumentNumber(settings.invoicePrefix, nextInvoiceSeq) + settingsUpdate.lastInvoiceSeq = nextInvoiceSeq + } + + if (Object.keys(settingsUpdate).length > 0) { + await tx.contractSettings.update({ + where: { companyId }, + data: settingsUpdate, + }) + } + + return tx.reservation.update({ + where: { id: reservationId }, + data, + select: { id: true, contractNumber: true, invoiceNumber: true }, + }) + }) +} + +function buildReservationInvoiceLineItems(reservation: { + dailyRate: number + totalDays: number + discountAmount: number + depositAmount: number + pricingRulesApplied: Array<{ name?: string; amount?: number; type?: string }> | null + insurances: Array<{ id: string; policyName: string; chargeType: string; chargeValue: number; totalCharge: number }> + additionalDrivers: Array<{ id: string; firstName: string; lastName: string; totalCharge: number }> + vehicle: { year: number; make: string; model: string } +}) { + const baseAmount = reservation.dailyRate * reservation.totalDays + const lineItems = [ + { + description: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`, + qty: reservation.totalDays, + unitPrice: reservation.dailyRate, + total: baseAmount, + category: 'RENTAL', + }, + ...reservation.insurances.map((insurance) => ({ + description: insurance.policyName, + qty: insurance.chargeType === 'PER_DAY' ? reservation.totalDays : 1, + unitPrice: insurance.chargeType === 'PER_DAY' ? insurance.chargeValue : insurance.totalCharge, + total: insurance.totalCharge, + category: 'INSURANCE', + })), + ...reservation.additionalDrivers + .filter((driver) => driver.totalCharge > 0) + .map((driver) => ({ + description: `Additional driver - ${driver.firstName} ${driver.lastName}`, + qty: 1, + unitPrice: driver.totalCharge, + total: driver.totalCharge, + category: 'ADDITIONAL_DRIVER', + })), + ...((reservation.pricingRulesApplied ?? []).map((rule, index) => ({ + description: rule.name?.trim() || `Pricing adjustment ${index + 1}`, + qty: 1, + unitPrice: Number(rule.amount ?? 0), + total: Number(rule.amount ?? 0), + category: 'PRICING_RULE', + }))), + ...(reservation.discountAmount > 0 ? [{ + description: 'Discount', + qty: 1, + unitPrice: -reservation.discountAmount, + total: -reservation.discountAmount, + category: 'DISCOUNT', + }] : []), + ...(reservation.depositAmount > 0 ? [{ + description: 'Security deposit', + qty: 1, + unitPrice: reservation.depositAmount, + total: reservation.depositAmount, + category: 'DEPOSIT', + }] : []), + ] + + return lineItems +} + async function assertReservationLicenseCompliance(reservationId: string, companyId: string) { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: reservationId, companyId }, @@ -178,6 +289,7 @@ router.post('/', async (req, res, next) => { totalAmount, depositAmount: body.depositAmount, notes: body.notes ?? null, + extras: body.paymentMode ? { paymentMode: body.paymentMode } : undefined, pricingRulesApplied: applied, pricingRulesTotal: pricingTotal, }, @@ -209,6 +321,181 @@ router.get('/:id', async (req, res, next) => { } catch (err) { next(err) } }) +router.get('/:id/contract', async (req, res, next) => { + try { + await ensureReservationDocumentNumbers(req.companyId, req.params.id) + + const reservation = await prisma.reservation.findFirstOrThrow({ + where: { id: req.params.id, companyId: req.companyId }, + include: { + vehicle: true, + customer: true, + insurances: true, + additionalDrivers: true, + rentalPayments: { orderBy: { createdAt: 'asc' } }, + inspections: { include: { damagePoints: true }, orderBy: { inspectedAt: 'asc' } }, + company: { + include: { + brand: true, + contractSettings: true, + accountingSettings: true, + }, + }, + }, + }) + + const contractSettings = reservation.company.contractSettings ?? await prisma.contractSettings.upsert({ + where: { companyId: req.companyId }, + update: {}, + create: { companyId: req.companyId }, + }) + + const paymentMode = reservation.extras && typeof reservation.extras === 'object' && !Array.isArray(reservation.extras) + ? String((reservation.extras as Record).paymentMode ?? '') + : '' + + const invoiceLineItems = buildReservationInvoiceLineItems({ + dailyRate: reservation.dailyRate, + totalDays: reservation.totalDays, + discountAmount: reservation.discountAmount, + depositAmount: reservation.depositAmount, + pricingRulesApplied: Array.isArray(reservation.pricingRulesApplied) ? reservation.pricingRulesApplied as Array<{ name?: string; amount?: number; type?: string }> : [], + insurances: reservation.insurances, + additionalDrivers: reservation.additionalDrivers, + vehicle: reservation.vehicle, + }) + + const subtotal = invoiceLineItems.reduce((sum, item) => sum + item.total, 0) + const taxes = contractSettings.showTax && contractSettings.taxRate + ? [{ + label: contractSettings.taxLabel?.trim() || 'Tax', + rate: contractSettings.taxRate, + amount: Math.round(subtotal * contractSettings.taxRate / 100), + }] + : [] + const taxTotal = taxes.reduce((sum, tax) => sum + tax.amount, 0) + const grandTotal = subtotal + taxTotal + const amountPaid = reservation.rentalPayments + .filter((payment) => payment.status === 'SUCCEEDED') + .reduce((sum, payment) => sum + payment.amount, 0) + + const checkInInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKIN') ?? null + const checkOutInspection = reservation.inspections.find((inspection) => inspection.type === 'CHECKOUT') ?? null + + res.json({ + data: { + reservationId: reservation.id, + contractNumber: reservation.contractNumber, + invoiceNumber: reservation.invoiceNumber, + generatedAt: new Date().toISOString(), + status: reservation.status, + paymentStatus: reservation.paymentStatus, + paymentMode: paymentMode || null, + notes: reservation.notes, + company: { + name: reservation.company.brand?.displayName ?? reservation.company.name, + legalName: contractSettings.legalName || reservation.company.name, + email: reservation.company.brand?.publicEmail ?? reservation.company.email, + phone: reservation.company.brand?.publicPhone ?? reservation.company.phone, + address: reservation.company.brand?.publicAddress ?? reservation.company.address ?? null, + city: reservation.company.brand?.publicCity ?? null, + country: reservation.company.brand?.publicCountry ?? null, + registrationNumber: contractSettings.registrationNumber, + taxId: contractSettings.taxId, + logoUrl: reservation.company.brand?.logoUrl ?? null, + }, + driver: { + firstName: reservation.customer.firstName, + lastName: reservation.customer.lastName, + email: reservation.customer.email, + phone: reservation.customer.phone, + dateOfBirth: reservation.customer.dateOfBirth, + driverLicense: reservation.customer.driverLicense, + licenseCountry: reservation.customer.licenseCountry, + licenseCategory: reservation.customer.licenseCategory, + licenseIssuedAt: reservation.customer.licenseIssuedAt, + licenseExpiry: reservation.customer.licenseExpiry, + nationality: reservation.customer.nationality, + }, + additionalDrivers: reservation.additionalDrivers.map((driver) => ({ + id: driver.id, + firstName: driver.firstName, + lastName: driver.lastName, + email: driver.email, + phone: driver.phone, + driverLicense: driver.driverLicense, + licenseIssuedAt: driver.licenseIssuedAt, + licenseExpiry: driver.licenseExpiry, + licenseCountry: driver.nationality, + dateOfBirth: driver.dateOfBirth, + totalCharge: driver.totalCharge, + })), + vehicle: { + make: reservation.vehicle.make, + model: reservation.vehicle.model, + year: reservation.vehicle.year, + color: reservation.vehicle.color, + licensePlate: reservation.vehicle.licensePlate, + vin: reservation.vehicle.vin, + category: reservation.vehicle.category, + }, + rentalPeriod: { + startDate: reservation.startDate, + endDate: reservation.endDate, + totalDays: reservation.totalDays, + pickupLocation: reservation.pickupLocation, + returnLocation: reservation.returnLocation, + }, + insurance: { + total: reservation.insuranceTotal, + policies: reservation.insurances.map((insurance) => ({ + id: insurance.id, + name: insurance.policyName, + chargeType: insurance.chargeType, + unitPrice: insurance.chargeValue, + totalCharge: insurance.totalCharge, + })), + }, + inspections: { + checkIn: checkInInspection, + checkOut: checkOutInspection, + }, + terms: { + terms: contractSettings.terms, + fuelPolicy: contractSettings.fuelPolicy, + fuelPolicyType: contractSettings.fuelPolicyType, + depositPolicy: contractSettings.depositPolicy, + lateFeePolicy: contractSettings.lateFeePolicy, + damagePolicy: contractSettings.damagePolicy, + footerNote: contractSettings.contractFooterNote, + signatureRequired: contractSettings.signatureRequired, + }, + invoice: { + currency: reservation.company.accountingSettings?.currency ?? reservation.company.brand?.defaultCurrency ?? 'MAD', + lineItems: invoiceLineItems, + subtotal, + taxes, + taxTotal, + total: grandTotal, + amountPaid, + balanceDue: grandTotal - amountPaid, + payments: reservation.rentalPayments.map((payment) => ({ + id: payment.id, + amount: payment.amount, + currency: payment.currency, + type: payment.type, + status: payment.status, + provider: payment.paymentProvider, + paymentMethod: payment.paymentMethod, + paidAt: payment.paidAt, + createdAt: payment.createdAt, + })), + }, + }, + }) + } catch (err) { next(err) } +}) + router.post('/:id/confirm', async (req, res, next) => { try { const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index 3198c0f..3c850b8 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -13,6 +13,15 @@ router.get('/plans', (_req, res) => { res.json({ data: PLAN_PRICES }) }) +router.get('/providers', (_req, res) => { + res.json({ + data: { + amanpay: amanpay.isConfigured(), + paypal: paypal.isConfigured(), + }, + }) +}) + // ─── AmanPay subscription webhook (no auth) ────────────────────────────────── router.post('/webhooks/amanpay', async (req, res, next) => { diff --git a/apps/dashboard/public/rentalcardrive.png b/apps/dashboard/public/rentalcardrive.png new file mode 100644 index 0000000..2fee055 Binary files /dev/null and b/apps/dashboard/public/rentalcardrive.png differ diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx index 0914ef5..f6c8721 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx @@ -1,465 +1,160 @@ 'use client' import { useEffect, useState } from 'react' -import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' +import Link from 'next/link' +import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' -type Plan = 'STARTER' | 'GROWTH' | 'PRO' -type BillingPeriod = 'MONTHLY' | 'ANNUAL' type Currency = 'MAD' | 'USD' | 'EUR' -interface Subscription { - id: string - plan: Plan - billingPeriod: BillingPeriod - status: string - currency: Currency - trialEndAt: string | null - currentPeriodEnd: string | null - cancelAtPeriodEnd: boolean -} - -interface Invoice { +type PaymentRow = { id: string amount: number currency: Currency status: string - paymentProvider: string + type: 'CHARGE' | 'DEPOSIT' + paymentProvider: 'AMANPAY' | 'PAYPAL' paidAt: string | null createdAt: string + reservation: { + id: string + customer: { firstName: string; lastName: string } + vehicle: { make: string; model: string; licensePlate: string } + } } const STATUS_BADGE: Record = { - TRIALING: 'bg-sky-100 text-sky-700', - ACTIVE: 'bg-green-100 text-green-700', - PAST_DUE: 'bg-amber-100 text-amber-700', - CANCELLED: 'bg-slate-100 text-slate-600', - UNPAID: 'bg-red-100 text-red-700', -} - -const INVOICE_STATUS: Record = { - PAID: 'bg-green-100 text-green-700', PENDING: 'bg-amber-100 text-amber-700', + SUCCEEDED: 'bg-green-100 text-green-700', FAILED: 'bg-red-100 text-red-700', REFUNDED: 'bg-slate-100 text-slate-600', + PARTIALLY_REFUNDED: 'bg-blue-100 text-blue-700', } -const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] -export default function BillingPage() { +export default function RentCarBillingPage() { const { language } = useDashboardI18n() - const [subscription, setSubscription] = useState(null) - const [invoices, setInvoices] = useState([]) + const [payments, setPayments] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - const [selectedPlan, setSelectedPlan] = useState('STARTER') - const [billingPeriod, setBillingPeriod] = useState('MONTHLY') - const [currency, setCurrency] = useState('MAD') - const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY') - const [paying, setPaying] = useState(false) - const [cancelling, setCancelling] = useState(false) const copy = { en: { - title: 'Billing', - subtitle: 'Manage your plan, payment provider, and invoice history.', - 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', + title: 'Rent Car Billing', + subtitle: 'Track rental payment transactions across all reservations.', + bookCar: 'Book car', + loading: 'Loading payments…', + empty: 'No rental payments yet.', + failed: 'Failed to load rental payments.', + reservation: 'Reservation', + customer: 'Customer', + vehicle: 'Vehicle', + type: 'Type', provider: 'Provider', status: 'Status', - paid: 'Paid', + created: 'Created', + paidAt: 'Paid at', amount: 'Amount', - loading: 'Loading…', - noInvoices: 'No invoices yet.', - statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record, - invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record, - 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, + charge: 'Charge', + deposit: 'Deposit', }, fr: { - title: 'Facturation', - subtitle: 'Gérez votre plan, le prestataire de paiement et l’historique des factures.', - trial: 'Essai gratuit', - remaining: 'restants. Abonnez-vous avant la fin pour garder l’accè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: 'S’abonner', - 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: 'S’abonner maintenant', - invoiceHistory: 'Historique des factures', - date: 'Date', + title: 'Facturation location', + subtitle: 'Suivez les paiements de location sur toutes les réservations.', + bookCar: 'Réserver une voiture', + loading: 'Chargement des paiements…', + empty: 'Aucun paiement de location pour le moment.', + failed: 'Échec du chargement des paiements de location.', + reservation: 'Réservation', + customer: 'Client', + vehicle: 'Véhicule', + type: 'Type', provider: 'Prestataire', status: 'Statut', - paid: 'Payé', + created: 'Créé le', + paidAt: 'Payé le', amount: 'Montant', - loading: 'Chargement…', - noInvoices: 'Aucune facture pour le moment.', - statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record, - invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record, - 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, + charge: 'Paiement', + deposit: 'Dépôt', }, 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: 'التاريخ', + title: 'فوترة تأجير السيارات', + subtitle: 'تتبع معاملات دفع الإيجار عبر جميع الحجوزات.', + bookCar: 'حجز سيارة', + loading: 'جارٍ تحميل المدفوعات…', + empty: 'لا توجد مدفوعات تأجير حتى الآن.', + failed: 'فشل تحميل مدفوعات التأجير.', + reservation: 'الحجز', + customer: 'العميل', + vehicle: 'المركبة', + type: 'النوع', provider: 'المزوّد', status: 'الحالة', - paid: 'مدفوع', + created: 'تاريخ الإنشاء', + paidAt: 'تاريخ الدفع', amount: 'المبلغ', - loading: 'جارٍ التحميل…', - noInvoices: 'لا توجد فواتير حتى الآن.', - statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record, - invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record, - planFeatures: { - STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'], - GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'], - PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'], - } as Record, + charge: 'دفعة', + deposit: 'عربون', }, }[language] useEffect(() => { - Promise.all([ - apiFetch('/subscriptions/me'), - apiFetch('/subscriptions/invoices'), - ]) - .then(([sub, inv]) => { - if (sub) { - setSubscription(sub) - setSelectedPlan(sub.plan) - setBillingPeriod(sub.billingPeriod) - setCurrency(sub.currency as Currency) - } - setInvoices(inv ?? []) - }) - .catch((err) => setError(err.message)) + apiFetch('/payments/company') + .then((rows) => setPayments(rows ?? [])) + .catch((err) => setError(err.message ?? copy.failed)) .finally(() => setLoading(false)) }, []) - async function handleCheckout() { - setPaying(true) - setError(null) - try { - const base = window.location.origin - const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', { - method: 'POST', - body: JSON.stringify({ - plan: selectedPlan, - billingPeriod, - currency, - provider, - successUrl: `${base}/dashboard/billing?payment=success`, - failureUrl: `${base}/dashboard/billing?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('/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('/subscriptions/resume', { method: 'POST' }) - setSubscription(sub) - } catch (err: any) { - setError(err.message) - } finally { - setCancelling(false) - } - } - - const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency] - const daysLeft = subscription?.trialEndAt - ? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000) - : null - return ( -
-
-

{copy.title}

-

{copy.subtitle}

+
+
+
+

{copy.title}

+

{copy.subtitle}

+
+ + {copy.bookCar} +
- {error && ( -
{error}
- )} + {error ?
{error}
: null} - {/* Trial banner */} - {subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && ( -
-

- {copy.trial} — {daysLeft} days {copy.remaining} -

-
- )} - - {/* Current plan */} - {subscription && ( -
-
-
-

{copy.currentPlan}

-
-

{subscription.plan}

- - {copy.statusLabels[subscription.status] ?? subscription.status} - -
-

- {subscription.billingPeriod} · {subscription.currency} - {subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`} -

- {subscription.cancelAtPeriodEnd && ( -

- {copy.cancelScheduled}{' '} - -

- )} -
- {!subscription.cancelAtPeriodEnd && ( - - )} -
-
- )} - - {/* Plan selector + checkout */} -
-
-

- {subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe} -

-

{copy.selectPlan}

-
- - {/* Billing period toggle */} -
- {(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => ( - - ))} -
- - {/* Currency selector */} -
- {(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => ( - - ))} -
- - {/* Plan cards */} -
- {PLANS.map((plan) => { - const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency] - const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE' - return ( - - ) - })} -
- - {/* Provider selector */} -
-

{copy.paymentProvider}

-
- {(['AMANPAY', 'PAYPAL'] as const).map((p) => ( - - ))} -
-
- - {/* Checkout CTA */} -
-
-

{copy.total}

-

- {planPrice ? formatCurrency(planPrice, currency) : '—'} - /{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear} -

-
- -
-
- - {/* Invoice history */}
-
-

{copy.invoiceHistory}

-
- + + + + - + + {loading ? ( - - ) : invoices.length === 0 ? ( - - ) : invoices.map((inv) => ( - - - + + ) : payments.length === 0 ? ( + + ) : payments.map((row) => ( + + + + + + - - + + + ))} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx new file mode 100644 index 0000000..5cdb198 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/[id]/page.tsx @@ -0,0 +1,954 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { formatCurrency } from '@rentaldrivego/types' +import { apiFetch } from '@/lib/api' +import { useDashboardI18n } from '@/components/I18nProvider' + +type DamagePoint = { + id?: string + x: number + y: number + damageType: 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER' + severity: 'MINOR' | 'MODERATE' | 'MAJOR' + description?: string | null +} + +type ContractPayload = { + reservationId: string + contractNumber: string | null + invoiceNumber: string | null + generatedAt: string + status: string + paymentStatus: string + paymentMode: string | null + notes: string | null + company: { + name: string + legalName: string + email: string | null + phone: string | null + address: string | Record | null + city: string | null + country: string | null + registrationNumber: string | null + taxId: string | null + logoUrl: string | null + } + driver: { + firstName: string + lastName: string + email: string + phone: string | null + dateOfBirth: string | null + driverLicense: string | null + licenseCountry: string | null + licenseCategory: string | null + licenseIssuedAt: string | null + licenseExpiry: string | null + nationality: string | null + } + additionalDrivers: Array<{ + id: string + firstName: string + lastName: string + email: string | null + phone: string | null + driverLicense: string + licenseIssuedAt: string | null + licenseExpiry: string | null + licenseCountry: string | null + dateOfBirth: string | null + totalCharge: number + }> + vehicle: { + make: string + model: string + year: number + color: string + licensePlate: string + vin: string | null + category: string + } + rentalPeriod: { + startDate: string + endDate: string + totalDays: number + pickupLocation: string | null + returnLocation: string | null + } + insurance: { + total: number + policies: Array<{ + id: string + name: string + chargeType: string + unitPrice: number + totalCharge: number + }> + } + inspections: { + checkIn: { + mileage: number | null + fuelLevel: string + generalCondition: string | null + employeeNotes: string | null + damagePoints: DamagePoint[] + } | null + checkOut: { + mileage: number | null + fuelLevel: string + generalCondition: string | null + employeeNotes: string | null + damagePoints: DamagePoint[] + } | null + } + terms: { + terms: string | null + fuelPolicy: string | null + fuelPolicyType: string | null + depositPolicy: string | null + lateFeePolicy: string | null + damagePolicy: string | null + footerNote: string | null + signatureRequired: boolean + } + invoice: { + currency: string + lineItems: Array<{ + description: string + qty: number + unitPrice: number + total: number + category: string + }> + subtotal: number + taxes: Array<{ label: string; rate: number; amount: number }> + taxTotal: number + total: number + amountPaid: number + balanceDue: number + payments: Array<{ + id: string + amount: number + currency: string + type: string + status: string + provider: string + paymentMethod: string | null + paidAt: string | null + createdAt: string + }> + } +} + +const severityColors: Record = { + MINOR: '#f59e0b', + MODERATE: '#f97316', + MAJOR: '#dc2626', +} + +function VehicleDamageDiagram({ points }: { points: DamagePoint[] }) { + return ( + + + + + + + + + + {points.map((point, index) => ( + + ))} + + ) +} + +function formatAddress(address: string | Record | null, city: string | null, country: string | null) { + const lines: string[] = [] + if (typeof address === 'string' && address.trim()) lines.push(address.trim()) + if (address && typeof address === 'object' && !Array.isArray(address)) { + for (const value of Object.values(address)) { + if (typeof value === 'string' && value.trim()) lines.push(value.trim()) + } + } + if (city) lines.push(city) + if (country) lines.push(country) + return lines.join(', ') +} + +function formatDateOnly(value: string | null | undefined, localeCode: string, fallback: string) { + return value + ? new Date(value).toLocaleDateString(localeCode, { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : fallback +} + +function splitTerms(text: string | null | undefined) { + if (!text?.trim()) return [] + return text + .split(/\n\s*\n|\n(?=\d+\.)/) + .map((item) => item.replace(/^\d+\.\s*/, '').trim()) + .filter(Boolean) +} + +function PaperField({ + label, + value, + className = '', +}: { + label: string + value: string + className?: string +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} + +function PaperSection({ + title, + children, + className = '', +}: { + title: string + children: React.ReactNode + className?: string +}) { + return ( +
+
+ {title} +
+
{children}
+
+ ) +} + +export default function ContractDetailPage() { + const { id } = useParams<{ id: string }>() + const { language } = useDashboardI18n() + const [contract, setContract] = useState(null) + const [error, setError] = useState(null) + + const copy = { + en: { + back: 'Back to contracts', + booking: 'Booking', + print: 'Print', + generated: 'Generated', + status: 'Status', + paymentStatus: 'Payment status', + paymentMode: 'Payment mode', + company: 'Rental company', + driver: 'Primary driver', + additionalDrivers: 'Additional drivers', + vehicle: 'Vehicle', + period: 'Rental period', + pickup: 'Pickup', + dropoff: 'Return', + start: 'Start', + end: 'End', + contractTerms: 'Contract terms', + insurance: 'Insurance', + inspections: 'Vehicle condition and damage', + noDamage: 'No damage markers recorded at check-in.', + invoice: 'Invoice', + subtotal: 'Subtotal', + paid: 'Paid', + balance: 'Balance due', + payments: 'Payments', + notes: 'Booking notes', + footer: 'Footer note', + none: 'Not provided', + contractNo: 'Contract #', + invoiceNo: 'Invoice #', + fuel: 'Fuel policy', + deposit: 'Deposit policy', + lateFees: 'Late fee policy', + damage: 'Damage policy', + termsLabel: 'Terms', + signature: 'Signature required', + customerDamage: 'Check-in inspection', + mileage: 'Mileage', + fuelLevel: 'Fuel level', + unitPrice: 'Unit price', + qty: 'Qty', + total: 'Total', + actionOpenBooking: 'Open booking', + noAdditionalDrivers: 'No additional drivers.', + noInsurance: 'No insurance selected.', + noPayments: 'No payments recorded.', + loading: 'Loading contract…', + yes: 'Yes', + no: 'No', + agreementTitle: 'Rental Agreement', + agreementForReservation: 'For reservation', + draftAgreement: 'Draft agreement', + customerInformation: 'Customer information', + rentalPeriodDetails: 'Rental period and driver details', + insuranceCondition: 'Insurance and vehicle condition', + acknowledgementSignature: 'Driver acknowledgement and signature', + vehicleInformation: 'Rental vehicle information', + rentalRate: 'Car rental rate', + billingSummary: 'Payment and billing summary', + damageDiagram: 'Vehicle damage diagram', + customerName: 'Customer name', + homeAddress: 'Home address', + cityCountry: 'City / Country', + telephone: 'Telephone', + emailLabel: 'E-mail', + driverLicenseNumber: "Driver's license no.", + nationalityLabel: 'Nationality', + birthDate: 'Birth date', + issuedExpires: 'Issued / Expires', + dateOut: 'Date out', + dateDueIn: 'Date due in', + mileageOut: 'Mileage out', + mileageIn: 'Mileage in', + pickupLocationLabel: 'Pickup location', + returnLocationLabel: 'Return location', + insuranceSelections: 'Insurance selections', + checkInCondition: 'Check-in condition', + signatureAcknowledgement: + 'The renter confirms that the vehicle was received in acceptable condition, agrees to return it according to the rental policies, and accepts responsibility for authorized charges, violations, late fees, fuel adjustments, and damage assessment tied to this reservation.', + customerSignature: 'Customer signature', + companyRepresentative: 'Company representative', + yearMakeModel: 'Year / Make / Model', + licensePlateLabel: 'License plate', + vehicleColor: 'Vehicle color', + categoryLabel: 'Category', + vinLabel: 'VIN', + dailyRate: 'Daily rate', + rentalDays: 'Rental days', + insuranceTotal: 'Insurance total', + additionalDriverFees: 'Additional driver fees', + amountPaidLabel: 'Amount paid', + balanceDueLabel: 'Balance due', + lastPayment: 'Last payment', + roadsideAssistance: 'For after-hours roadside assistance or towing call:', + taxesLabel: 'Taxes', + totalChargeLabel: 'Total charge', + damageMarkersRecorded: (count: number) => `${count} damage marker(s) recorded at check-in.`, + termsAndConditions: 'Rental Agreement Terms and Conditions', + checkInMileageFuel: 'Check-in mileage / fuel', + checkOutMileageFuel: 'Check-out mileage / fuel', + damageEntries: 'Damage entries', + notesAndFooter: 'Notes and footer', + paymentsRecorded: (count: number) => `${count} payment(s) recorded.`, + clauseHeadings: [ + 'Vehicle and reservation', + 'Authorized use', + 'Fuel and return condition', + 'Damage liability', + 'Deposit and security hold', + 'Late return and extra charges', + 'Payment authorization', + 'General provisions', + ], + fallbackClauses: { + vehicleReservation: (vehicle: string) => `The renter accepts responsibility for the ${vehicle} during the rental period and agrees to return it in the same general condition, ordinary wear excepted.`, + authorizedUse: 'Only the primary driver and approved additional drivers may operate the vehicle. The renter is responsible for any fines, tolls, traffic violations, or unauthorized use during the reservation.', + fuel: 'The vehicle must be returned with the agreed fuel level. Any shortage may result in refueling charges.', + damage: 'Any loss or damage, including damage caused by negligence or misuse, may be charged to the renter according to company policy and applicable law.', + deposit: 'A security deposit may be held to secure unpaid balances, penalties, damage, or post-rental adjustments until the reservation is reconciled.', + lateFees: 'Late return, excess mileage, cleaning, or administrative fees may be applied based on the reservation terms and final inspection.', + paymentAuthorization: (company: string) => `The renter authorizes ${company} to process charges related to this reservation, including rental charges, taxes, optional coverages, additional drivers, and approved post-rental adjustments.`, + general: "This agreement remains subject to the company's full rental policies, final inspection, and applicable local regulations.", + }, + contractStatusLabels: { DRAFT: 'Draft', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' }, + paymentStatusLabels: { UNPAID: 'Unpaid', PAID: 'Paid', PARTIAL: 'Partial', REFUNDED: 'Refunded', OVERDUE: 'Overdue', PENDING: 'Pending', COMPLETED: 'Completed', FAILED: 'Failed' }, + chargeTypeLabels: { PER_DAY: 'Per day', FLAT: 'Flat fee', FLAT_RATE: 'Flat fee', PER_RENTAL: 'Per rental' }, + damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' }, + severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' }, + fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' }, + paymentProviderLabels: { CASH: 'Cash', STRIPE: 'Stripe', BANK_TRANSFER: 'Bank transfer', CHECK: 'Check', PAYPAL: 'PayPal', CREDIT_CARD: 'Credit card', DEBIT_CARD: 'Debit card' }, + }, + fr: { + back: 'Retour aux contrats', + booking: 'Réservation', + print: 'Imprimer', + generated: 'Généré', + status: 'Statut', + paymentStatus: 'Statut du paiement', + paymentMode: 'Mode de paiement', + company: 'Société de location', + driver: 'Conducteur principal', + additionalDrivers: 'Conducteurs supplémentaires', + vehicle: 'Véhicule', + period: 'Période de location', + pickup: 'Départ', + dropoff: 'Retour', + start: 'Début', + end: 'Fin', + contractTerms: 'Clauses du contrat', + insurance: 'Assurance', + inspections: 'État du véhicule et dommages', + noDamage: 'Aucun dommage enregistré au départ.', + invoice: 'Facture', + subtotal: 'Sous-total', + paid: 'Payé', + balance: 'Solde dû', + payments: 'Paiements', + notes: 'Notes de réservation', + footer: 'Note de bas de page', + none: 'Non renseigné', + contractNo: 'Contrat #', + invoiceNo: 'Facture #', + fuel: 'Politique carburant', + deposit: 'Politique de dépôt', + lateFees: 'Politique de retard', + damage: 'Politique dommages', + termsLabel: 'Conditions', + signature: 'Signature requise', + customerDamage: 'Inspection de départ', + mileage: 'Kilométrage', + fuelLevel: 'Niveau de carburant', + unitPrice: 'Prix unitaire', + qty: 'Qté', + total: 'Total', + actionOpenBooking: 'Ouvrir la réservation', + noAdditionalDrivers: 'Aucun conducteur supplémentaire.', + noInsurance: 'Aucune assurance sélectionnée.', + noPayments: 'Aucun paiement enregistré.', + loading: 'Chargement du contrat…', + yes: 'Oui', + no: 'Non', + agreementTitle: 'Contrat de location', + agreementForReservation: 'Pour la réservation', + draftAgreement: 'Brouillon de contrat', + customerInformation: 'Informations client', + rentalPeriodDetails: 'Période de location et conducteur', + insuranceCondition: 'Assurance et état du véhicule', + acknowledgementSignature: 'Reconnaissance et signature', + vehicleInformation: 'Informations véhicule', + rentalRate: 'Tarification de location', + billingSummary: 'Résumé de facturation', + damageDiagram: 'Schéma des dommages du véhicule', + customerName: 'Nom du client', + homeAddress: 'Adresse', + cityCountry: 'Ville / Pays', + telephone: 'Téléphone', + emailLabel: 'E-mail', + driverLicenseNumber: 'N° de permis', + nationalityLabel: 'Nationalité', + birthDate: 'Date de naissance', + issuedExpires: 'Délivré / Expire', + dateOut: 'Date de sortie', + dateDueIn: 'Date de retour prévue', + mileageOut: 'Kilométrage sortie', + mileageIn: 'Kilométrage retour', + pickupLocationLabel: 'Lieu de départ', + returnLocationLabel: 'Lieu de retour', + insuranceSelections: 'Assurances sélectionnées', + checkInCondition: 'État au départ', + signatureAcknowledgement: + "Le locataire confirme avoir reçu le véhicule dans un état acceptable, s'engage à le restituer selon les politiques de location et accepte la responsabilité des frais autorisés, infractions, pénalités de retard, ajustements carburant et constats de dommages liés à cette réservation.", + customerSignature: 'Signature du client', + companyRepresentative: 'Représentant de la société', + yearMakeModel: 'Année / Marque / Modèle', + licensePlateLabel: "Plaque d'immatriculation", + vehicleColor: 'Couleur du véhicule', + categoryLabel: 'Catégorie', + vinLabel: 'VIN', + dailyRate: 'Tarif journalier', + rentalDays: 'Jours de location', + insuranceTotal: 'Total assurance', + additionalDriverFees: 'Frais conducteur supplémentaire', + amountPaidLabel: 'Montant payé', + balanceDueLabel: 'Solde dû', + lastPayment: 'Dernier paiement', + roadsideAssistance: 'Pour une assistance routière ou un remorquage hors horaires, appelez :', + taxesLabel: 'Taxes', + totalChargeLabel: 'Montant total', + damageMarkersRecorded: (count: number) => `${count} repère(s) de dommage enregistré(s) au départ.`, + termsAndConditions: 'Conditions générales du contrat de location', + checkInMileageFuel: 'Kilométrage / carburant départ', + checkOutMileageFuel: 'Kilométrage / carburant retour', + damageEntries: 'Dommages enregistrés', + notesAndFooter: 'Notes et pied de page', + paymentsRecorded: (count: number) => `${count} paiement(s) enregistré(s).`, + clauseHeadings: [ + 'Véhicule et réservation', + 'Usage autorisé', + 'Carburant et restitution', + 'Responsabilité dommages', + 'Dépôt de garantie', + 'Retard et frais supplémentaires', + 'Autorisation de paiement', + 'Dispositions générales', + ], + fallbackClauses: { + vehicleReservation: (vehicle: string) => `Le locataire accepte la responsabilité du ${vehicle} pendant la période de location et s'engage à le restituer dans le même état général, hors usure normale.`, + authorizedUse: 'Seuls le conducteur principal et les conducteurs supplémentaires approuvés peuvent utiliser le véhicule. Le locataire reste responsable des amendes, péages, infractions routières ou usages non autorisés durant la réservation.', + fuel: 'Le véhicule doit être restitué avec le niveau de carburant convenu. Toute insuffisance peut entraîner des frais de ravitaillement.', + damage: 'Toute perte ou tout dommage, y compris causé par négligence ou mauvaise utilisation, peut être facturé au locataire selon la politique de la société et la réglementation applicable.', + deposit: "Un dépôt de garantie peut être retenu pour couvrir les soldes impayés, pénalités, dommages ou ajustements post-location jusqu'à la clôture complète de la réservation.", + lateFees: "Des frais de retard, kilométrage excédentaire, nettoyage ou gestion peuvent être appliqués selon les conditions de réservation et l'inspection finale.", + paymentAuthorization: (company: string) => `Le locataire autorise ${company} à traiter les frais liés à cette réservation, y compris location, taxes, garanties optionnelles, conducteurs supplémentaires et ajustements post-location approuvés.`, + general: "Le présent accord reste soumis aux politiques complètes de la société, à l'inspection finale et à la réglementation locale applicable.", + }, + contractStatusLabels: { DRAFT: 'Brouillon', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' }, + paymentStatusLabels: { UNPAID: 'Non payé', PAID: 'Payé', PARTIAL: 'Partiel', REFUNDED: 'Remboursé', OVERDUE: 'En retard', PENDING: 'En attente', COMPLETED: 'Complété', FAILED: 'Échoué' }, + chargeTypeLabels: { PER_DAY: 'Par jour', FLAT: 'Forfait', FLAT_RATE: 'Forfait', PER_RENTAL: 'Par location' }, + damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' }, + severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' }, + fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' }, + paymentProviderLabels: { CASH: 'Espèces', STRIPE: 'Stripe', BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque', PAYPAL: 'PayPal', CREDIT_CARD: 'Carte crédit', DEBIT_CARD: 'Carte débit' }, + }, + ar: { + back: 'العودة إلى العقود', + booking: 'الحجز', + print: 'طباعة', + generated: 'تم الإنشاء', + status: 'الحالة', + paymentStatus: 'حالة الدفع', + paymentMode: 'طريقة الدفع', + company: 'شركة التأجير', + driver: 'السائق الرئيسي', + additionalDrivers: 'السائقون الإضافيون', + vehicle: 'المركبة', + period: 'مدة الإيجار', + pickup: 'الاستلام', + dropoff: 'التسليم', + start: 'البداية', + end: 'النهاية', + contractTerms: 'شروط العقد', + insurance: 'التأمين', + inspections: 'حالة المركبة والأضرار', + noDamage: 'لا توجد أضرار مسجلة عند الاستلام.', + invoice: 'الفاتورة', + subtotal: 'الإجمالي الفرعي', + paid: 'المدفوع', + balance: 'الرصيد المستحق', + payments: 'المدفوعات', + notes: 'ملاحظات الحجز', + footer: 'ملاحظة التذييل', + none: 'غير متوفر', + contractNo: 'رقم العقد', + invoiceNo: 'رقم الفاتورة', + fuel: 'سياسة الوقود', + deposit: 'سياسة العربون', + lateFees: 'سياسة التأخير', + damage: 'سياسة الأضرار', + termsLabel: 'الشروط', + signature: 'التوقيع مطلوب', + customerDamage: 'فحص الاستلام', + mileage: 'عداد المسافة', + fuelLevel: 'مستوى الوقود', + unitPrice: 'سعر الوحدة', + qty: 'الكمية', + total: 'الإجمالي', + actionOpenBooking: 'فتح الحجز', + noAdditionalDrivers: 'لا يوجد سائقون إضافيون.', + noInsurance: 'لم يتم اختيار تأمين.', + noPayments: 'لا توجد مدفوعات مسجلة.', + loading: 'جار تحميل العقد…', + yes: 'نعم', + no: 'لا', + agreementTitle: 'عقد تأجير', + agreementForReservation: 'للحجز', + draftAgreement: 'مسودة العقد', + customerInformation: 'معلومات العميل', + rentalPeriodDetails: 'مدة التأجير وبيانات السائق', + insuranceCondition: 'التأمين وحالة المركبة', + acknowledgementSignature: 'إقرار السائق والتوقيع', + vehicleInformation: 'معلومات مركبة التأجير', + rentalRate: 'سعر التأجير', + billingSummary: 'ملخص الفوترة', + damageDiagram: 'مخطط أضرار المركبة', + customerName: 'اسم العميل', + homeAddress: 'العنوان', + cityCountry: 'المدينة / الدولة', + telephone: 'الهاتف', + emailLabel: 'البريد الإلكتروني', + driverLicenseNumber: 'رقم رخصة القيادة', + nationalityLabel: 'الجنسية', + birthDate: 'تاريخ الميلاد', + issuedExpires: 'الإصدار / الانتهاء', + dateOut: 'تاريخ الخروج', + dateDueIn: 'تاريخ الإرجاع', + mileageOut: 'عداد الخروج', + mileageIn: 'عداد الإرجاع', + pickupLocationLabel: 'مكان الاستلام', + returnLocationLabel: 'مكان التسليم', + insuranceSelections: 'خيارات التأمين', + checkInCondition: 'الحالة عند الاستلام', + signatureAcknowledgement: + 'يقر المستأجر بأنه استلم المركبة بحالة مقبولة، ويلتزم بإعادتها وفق سياسات التأجير، ويقبل المسؤولية عن الرسوم المعتمدة والمخالفات ورسوم التأخير وتسويات الوقود وتقييمات الأضرار المرتبطة بهذا الحجز.', + customerSignature: 'توقيع العميل', + companyRepresentative: 'ممثل الشركة', + yearMakeModel: 'السنة / الصانع / الطراز', + licensePlateLabel: 'لوحة المركبة', + vehicleColor: 'لون المركبة', + categoryLabel: 'الفئة', + vinLabel: 'رقم الهيكل', + dailyRate: 'السعر اليومي', + rentalDays: 'أيام التأجير', + insuranceTotal: 'إجمالي التأمين', + additionalDriverFees: 'رسوم السائق الإضافي', + amountPaidLabel: 'المبلغ المدفوع', + balanceDueLabel: 'الرصيد المستحق', + lastPayment: 'آخر دفعة', + roadsideAssistance: 'للمساعدة على الطريق أو السحب خارج ساعات العمل اتصل بـ:', + taxesLabel: 'الضرائب', + totalChargeLabel: 'إجمالي الرسوم', + damageMarkersRecorded: (count: number) => `تم تسجيل ${count} علامة/علامات ضرر عند الاستلام.`, + termsAndConditions: 'الشروط والأحكام لعقد التأجير', + checkInMileageFuel: 'عداد / وقود الاستلام', + checkOutMileageFuel: 'عداد / وقود الإرجاع', + damageEntries: 'الأضرار المسجلة', + notesAndFooter: 'الملاحظات والتذييل', + paymentsRecorded: (count: number) => `تم تسجيل ${count} دفعة/دفعات.`, + clauseHeadings: [ + 'المركبة والحجز', + 'الاستخدام المصرح', + 'الوقود وحالة الإرجاع', + 'مسؤولية الأضرار', + 'وديعة الضمان', + 'التأخير والرسوم الإضافية', + 'تفويض الدفع', + 'أحكام عامة', + ], + fallbackClauses: { + vehicleReservation: (vehicle: string) => `يقبل المستأجر المسؤولية عن ${vehicle} طوال مدة التأجير، ويتعهد بإعادته بنفس الحالة العامة باستثناء الاستهلاك العادي.`, + authorizedUse: 'يُسمح فقط للسائق الرئيسي والسائقين الإضافيين المعتمدين بقيادة المركبة. ويتحمل المستأجر مسؤولية أي غرامات أو رسوم طرق أو مخالفات مرورية أو استخدام غير مصرح به أثناء الحجز.', + fuel: 'يجب إعادة المركبة بمستوى الوقود المتفق عليه. وأي نقص قد يترتب عليه رسوم تعبئة.', + damage: 'أي فقدان أو ضرر، بما في ذلك الناتج عن الإهمال أو سوء الاستخدام، قد يتم تحميله على المستأجر وفق سياسة الشركة والأنظمة المعمول بها.', + deposit: 'قد يتم حجز وديعة ضمان لتغطية الأرصدة غير المسددة أو الغرامات أو الأضرار أو التسويات اللاحقة إلى حين إقفال الحجز بشكل نهائي.', + lateFees: 'قد تُفرض رسوم التأخير أو الكيلومترات الزائدة أو التنظيف أو الرسوم الإدارية وفق شروط الحجز والفحص النهائي.', + paymentAuthorization: (company: string) => `يفوض المستأجر ${company} بتحصيل الرسوم المتعلقة بهذا الحجز، بما في ذلك قيمة التأجير والضرائب والتغطيات الاختيارية والسائقين الإضافيين والتسويات المعتمدة بعد الإرجاع.`, + general: 'يبقى هذا الاتفاق خاضعًا لسياسات الشركة الكاملة والفحص النهائي والأنظمة المحلية المعمول بها.', + }, + contractStatusLabels: { DRAFT: 'مسودة', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' }, + paymentStatusLabels: { UNPAID: 'غير مدفوع', PAID: 'مدفوع', PARTIAL: 'جزئي', REFUNDED: 'مسترد', OVERDUE: 'متأخر', PENDING: 'قيد الانتظار', COMPLETED: 'مكتمل', FAILED: 'فشل' }, + chargeTypeLabels: { PER_DAY: 'يومي', FLAT: 'مقطوع', FLAT_RATE: 'مقطوع', PER_RENTAL: 'لكل إيجار' }, + damageTypeLabels: { SCRATCH: 'خدش', DENT: 'دهس', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' }, + severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'خطير' }, + fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' }, + paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' }, + }, + }[language] + + useEffect(() => { + apiFetch(`/reservations/${id}/contract`) + .then((data) => { + setContract(data) + setError(null) + }) + .catch((err) => setError(err.message ?? 'Failed to load contract')) + }, [id]) + + const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US' + const formatDateTime = (value: string | null | undefined) => + value + ? new Date(value).toLocaleString(localeCode, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + : copy.none + + const address = useMemo(() => ( + contract ? formatAddress(contract.company.address, contract.company.city, contract.company.country) : '' + ), [contract]) + + if (error) return
{error}
+ if (!contract) return
{copy.loading}
+ + const tl = (map: Record, key: string) => map[key] ?? key + + const damagePoints = contract.inspections.checkIn?.damagePoints ?? [] + const rentalLine = contract.invoice.lineItems.find((item) => item.category === 'RENTAL') ?? contract.invoice.lineItems[0] + const depositLine = contract.invoice.lineItems.find((item) => item.category === 'DEPOSIT') + const additionalDriverLine = contract.invoice.lineItems.filter((item) => item.category === 'ADDITIONAL_DRIVER') + const clauses = splitTerms(contract.terms.terms) + const vehicleLabel = `${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}` + const fallbackClauses = [ + copy.fallbackClauses.vehicleReservation(vehicleLabel), + copy.fallbackClauses.authorizedUse, + copy.fallbackClauses.fuel, + copy.fallbackClauses.damage, + copy.fallbackClauses.deposit, + copy.fallbackClauses.lateFees, + copy.fallbackClauses.paymentAuthorization(contract.company.name), + copy.fallbackClauses.general, + ] + const agreementClauses = clauses.length > 0 ? clauses : fallbackClauses + const roadsidePhone = contract.company.phone ?? copy.none + const latestPayment = contract.invoice.payments.at(-1) + const driverFullName = `${contract.driver.firstName} ${contract.driver.lastName}` + const additionalDriverNames = contract.additionalDrivers.length > 0 + ? contract.additionalDrivers.map((driver) => `${driver.firstName} ${driver.lastName}`).join(', ') + : copy.noAdditionalDrivers + + return ( +
+
+
+ {copy.back} +

{contract.company.name}

+
+

{contract.contractNumber ?? copy.contractNo}

+ {tl(copy.contractStatusLabels, contract.status)} + {tl(copy.paymentStatusLabels, contract.paymentStatus)} +
+

+ {copy.invoiceNo}: {contract.invoiceNumber ?? '—'} · {copy.generated}: {formatDateTime(contract.generatedAt)} +

+
+
+ + {copy.actionOpenBooking} + + +
+
+ +
+
+
+
+ {contract.company.logoUrl && ( +
+ {contract.company.name} +
+ )} +
+

+ {contract.company.name} +

+

+ {contract.company.name} {copy.agreementTitle} +

+

+ {copy.agreementForReservation} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()} +

+
+
+
+

{copy.status}

+

{tl(copy.contractStatusLabels, contract.status)}

+
+
+

{copy.paymentStatus}

+

{tl(copy.paymentStatusLabels, contract.paymentStatus)}

+
+
+

{copy.contractNo}

+

{contract.contractNumber ?? '—'}

+
+
+

{copy.generated}

+

{formatDateTime(contract.generatedAt)}

+
+
+
+
+ +
+
+ +
+ + + + + + + + + +
+
+ + +
+ + + + + + + +
+
+ + +
+ 0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance} + className="bg-white" + /> + + + +
+
+ + +
+

{copy.signatureAcknowledgement}

+

+ {copy.signature}: {contract.terms.signatureRequired ? copy.yes : copy.no} +

+
+
+ {copy.customerSignature} +
+
+ {copy.companyRepresentative} +
+
+
+
+
+ +
+ +
+ + + + + +
+

{copy.damageDiagram}

+
+ +
+

+ {damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage} +

+
+
+
+ + +
+ + + + sum + item.total, 0), contract.invoice.currency)} className="bg-white" /> + + + 0 ? contract.invoice.taxes.map((tax) => `${tax.label} ${tax.rate}%`).join(', ') : copy.none} className="bg-white" /> + + + + +
+
+ + +
+ {contract.invoice.lineItems.map((item, index) => ( +
+
+

{item.description}

+

{copy.qty} {item.qty} · {formatCurrency(item.unitPrice, contract.invoice.currency)}

+
+

{formatCurrency(item.total, contract.invoice.currency)}

+
+ ))} + {latestPayment ? ( +
+ {copy.lastPayment}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)} +
+ ) : null} +
+
+
+
+ +
+ {copy.roadsideAssistance} {roadsidePhone} +
+
+ +
+
+

{copy.termsAndConditions}

+

+ {contract.company.name} · {contract.contractNumber ?? copy.draftAgreement} +

+
+ +
+ {agreementClauses.map((clause, index) => ( +
+

+ {index + 1}. {copy.clauseHeadings[index] ?? copy.clauseHeadings[copy.clauseHeadings.length - 1]} +

+

{clause}

+
+ ))} +
+ +
+ +
+ + + 0 ? damagePoints.map((point) => `${tl(copy.damageTypeLabels, point.damageType)} (${tl(copy.severityLabels, point.severity)})`).join(', ') : copy.noDamage} className="bg-white" /> +
+
+ + +
+
+

{copy.notes}

+

{contract.notes || copy.none}

+
+
+

{copy.footer}

+

{contract.terms.footerNote || copy.none}

+
+
+

{copy.payments}

+

{contract.invoice.payments.length > 0 ? copy.paymentsRecorded(contract.invoice.payments.length) : copy.noPayments}

+
+
+
+
+
+
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/contracts/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/page.tsx new file mode 100644 index 0000000..801f63a --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/dashboard/contracts/page.tsx @@ -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([]) + const [search, setSearch] = useState('') + const [error, setError] = useState(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 n’importe 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('/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 ( +
+
+
+

{copy.heading}

+

{copy.subtitle}

+
+ setSearch(event.target.value)} + placeholder={copy.search} + className="input-field w-full lg:max-w-md" + /> +
+ +
+ {error ? ( +
{error}
+ ) : ( +
+
{copy.date}{copy.reservation}{copy.customer}{copy.vehicle}{copy.type} {copy.provider} {copy.status}{copy.paid}{copy.created}{copy.paidAt} {copy.amount}
{copy.loading}
{copy.noInvoices}
{new Date(inv.createdAt).toLocaleDateString()}{inv.paymentProvider}
{copy.loading}
{copy.empty}
#{row.reservation.id.slice(0, 8)}{row.reservation.customer.firstName} {row.reservation.customer.lastName}{row.reservation.vehicle.make} {row.reservation.vehicle.model} · {row.reservation.vehicle.licensePlate}{row.type === 'DEPOSIT' ? copy.deposit : copy.charge}{row.paymentProvider} - - {copy.invoiceStatusLabels[inv.status] ?? inv.status} + + {row.status} - {inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'} - - {formatCurrency(inv.amount, inv.currency)} - {new Date(row.createdAt).toLocaleDateString()}{row.paidAt ? new Date(row.paidAt).toLocaleDateString() : '—'}{formatCurrency(row.amount, row.currency)}
+ + + + + + + + + + + + + + {filteredRows.map((row) => ( + + + + + + + + + + + ))} + {filteredRows.length === 0 && ( + + + + )} + +
{copy.customer}{copy.vehicle}{copy.dates}{copy.contract}{copy.invoice}{copy.status}{copy.total}{copy.action}
+

{row.customer.firstName} {row.customer.lastName}

+

{row.customer.email}

+
+

{row.vehicle.make} {row.vehicle.model}

+

{row.vehicle.licensePlate}

+
+

{formatDate(row.startDate)}

+

{formatDate(row.endDate)}

+
{row.contractNumber ?? '—'}{row.invoiceNumber ?? '—'} +
+ {row.status} + {row.paymentStatus} +
+
{formatCurrency(row.totalAmount, 'MAD')} + + {row.contractNumber ? copy.open : copy.generate} + +
{copy.empty}
+
+ )} +
+
+ ) +} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx index 3fbb0a8..7b27bf1 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/offers/page.tsx @@ -6,92 +6,789 @@ import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import { useDashboardI18n } from '@/components/I18nProvider' -interface OfferRow { +const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const +type Category = typeof CATEGORIES[number] + +interface Offer { id: string title: string - type: 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 - promoCode: string | null + 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']): string { + if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%` + if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD') + if (offer.type === 'FREE_DAY') return t.freeDay + if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} MAD/day` + return String(offer.discountValue) } export default function OffersPage() { const { language } = useDashboardI18n() - const [rows, setRows] = useState([]) - const [error, setError] = useState(null) - const copy = { - en: { - title: 'Offers', - subtitle: 'Promotions shown on your site and optionally on the marketplace.', - ends: 'ends', - active: 'Active', - inactive: 'Inactive', - marketplace: 'Marketplace', - featured: 'Featured', - empty: 'No offers created yet.', - }, - fr: { - title: 'Offres', - subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.', - ends: 'expire le', - active: 'Actif', - inactive: 'Inactif', - marketplace: 'Marketplace', - featured: 'Mise en avant', - empty: 'Aucune offre créée pour le moment.', - }, - ar: { - title: 'العروض', - subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.', - ends: 'تنتهي في', - active: 'نشط', - inactive: 'غير نشط', - marketplace: 'السوق', - featured: 'مميز', - empty: 'لا توجد عروض حتى الآن.', - }, - }[language] + const t = copy[language] - useEffect(() => { - apiFetch('/offers') - .then(setRows) + const [offers, setOffers] = useState([]) + const [error, setError] = useState(null) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(emptyForm) + const [formError, setFormError] = useState(null) + const [saving, setSaving] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const [fleet, setFleet] = useState([]) + const [loadingFleet, setLoadingFleet] = useState(false) + const [vehicleSearch, setVehicleSearch] = useState('') + + function load() { + apiFetch('/offers') + .then((data) => setOffers(data ?? [])) .catch((err) => setError(err.message)) - }, []) + } + + useEffect(() => { load() }, []) + + async function loadFleet() { + setLoadingFleet(true) + try { + const data = await apiFetch('/vehicles?pageSize=200') + setFleet(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(`/offers/${offer.id}`) + setForm((prev) => ({ + ...prev, + vehicleIds: 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 = { + 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 (
-
-

{copy.title}

-

{copy.subtitle}

+ {/* Header */} +
+
+

{t.title}

+

{t.subtitle}

+
+
+ + {error &&
{error}
} + + {/* Offer cards */}
- {rows.map((offer) => ( -
+ {offers.map((offer) => ( +
-
-

{offer.title}

-

{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}

+
+

{offer.title}

+

+ {t[`type${offer.type}` as keyof typeof t] as string} · {t.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')} +

- {offer.isActive ? copy.active : copy.inactive} + + {offer.isActive ? t.active : t.inactive} +
-
- {offer.isPublic && {copy.marketplace}} - {offer.isFeatured && {copy.featured}} + +

{discountLabel(offer, t)}

+ + {offer.description && ( +

{offer.description}

+ )} + +
+ {offer.isPublic && {t.marketplace}} + {offer.isFeatured && {t.featured}} {offer.promoCode && {offer.promoCode}} + {typeof offer.redemptionCount === 'number' && ( + {offer.redemptionCount} {t.redemptions} + )} +
+ +
+ +
-

- {offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')} -

))} - {rows.length === 0 && !error && ( -
{copy.empty}
+ + {offers.length === 0 && !error && ( +
{t.empty}
)}
- {error &&
{error}
} + + {/* Create / Edit Modal */} + {modalOpen && ( +
+
+

+ {editing ? t.editOffer : t.createOffer} +

+ +
+ {/* Title */} +
+ + setForm((f) => ({ ...f, title: e.target.value }))} + placeholder={t.placeholderTitle} + /> +
+ + {/* Description */} +
+ +