redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,830 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
tagline: string | null
|
||||
primaryColor: string
|
||||
accentColor?: string | null
|
||||
publicEmail: string | null
|
||||
publicPhone: string | null
|
||||
publicAddress?: string | null
|
||||
publicCity: string | null
|
||||
publicCountry?: string | null
|
||||
subdomain: string
|
||||
logoUrl?: string | null
|
||||
heroImageUrl?: string | null
|
||||
websiteUrl?: string | null
|
||||
whatsappNumber?: string | null
|
||||
paypalEmail: string | null
|
||||
amanpayMerchantId: string | null
|
||||
amanpaySecretKey?: string | null
|
||||
paypalMerchantId?: string | null
|
||||
customDomain?: string | null
|
||||
customDomainVerified?: boolean
|
||||
defaultLocale?: string
|
||||
defaultCurrency?: string
|
||||
isListedOnMarketplace: boolean
|
||||
}
|
||||
|
||||
interface ContractSettings {
|
||||
fuelPolicyType: string
|
||||
fuelPolicyNote: string | null
|
||||
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
|
||||
additionalDriverDailyRate: number
|
||||
additionalDriverFlatRate: number
|
||||
damagePolicy: string
|
||||
}
|
||||
|
||||
interface InsurancePolicy {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
chargeType: string
|
||||
chargeValue: number
|
||||
isRequired: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface PricingRule {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
condition: string
|
||||
conditionValue: number
|
||||
adjustmentType: string
|
||||
adjustmentValue: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
interface AccountingSettings {
|
||||
reportingPeriod: string
|
||||
accountantEmail: string | null
|
||||
accountantName: string | null
|
||||
autoSendReport: boolean
|
||||
reportFormat: string
|
||||
}
|
||||
|
||||
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
|
||||
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Advanced settings',
|
||||
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
|
||||
save: 'Saving…',
|
||||
saveBrand: 'Save brand',
|
||||
brandProfile: 'Brand and public profile',
|
||||
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
|
||||
payments: 'Rental payment methods',
|
||||
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
|
||||
savePayments: 'Save payments',
|
||||
customDomain: 'Custom domain',
|
||||
customDomainDesc: 'Point your own domain to the branded booking site.',
|
||||
saveDomain: 'Save domain',
|
||||
removeDomain: 'Remove custom domain',
|
||||
contractPolicies: 'Contract and driver policies',
|
||||
savePolicies: 'Save policies',
|
||||
insurancePolicies: 'Insurance policies',
|
||||
pricingRules: 'Pricing rules',
|
||||
accounting: 'Accounting and exports',
|
||||
saveAccounting: 'Save accounting',
|
||||
displayName: 'Display name',
|
||||
tagline: 'Tagline',
|
||||
publicEmail: 'Public email',
|
||||
publicPhone: 'Public phone',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
websiteUrl: 'Website URL',
|
||||
contractLanguage: 'Contract language',
|
||||
whatsapp: 'WhatsApp number',
|
||||
brandColor: 'Brand color',
|
||||
listedMarketplace: 'Listed on marketplace',
|
||||
logoUpload: 'Logo upload',
|
||||
heroUpload: 'Hero image upload',
|
||||
uploading: 'Uploading…',
|
||||
logoUploaded: 'Logo uploaded',
|
||||
noLogo: 'No logo uploaded yet',
|
||||
heroUploaded: 'Hero image uploaded',
|
||||
noHero: 'No hero image uploaded yet',
|
||||
subdomain: 'Subdomain',
|
||||
customDomainLabel: 'Custom domain',
|
||||
domainPlaceholder: 'cars.example.com',
|
||||
status: 'Status',
|
||||
verified: 'Verified',
|
||||
pendingDns: 'Pending DNS verification',
|
||||
notConfigured: 'Not configured',
|
||||
fuelPolicyType: 'Fuel policy type',
|
||||
additionalDriverCharge: 'Additional driver charge',
|
||||
dailyDriverRate: 'Daily driver rate',
|
||||
flatDriverRate: 'Flat driver rate',
|
||||
fuelPolicyNote: 'Fuel policy note',
|
||||
damagePolicy: 'Damage policy',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
noInsurance: 'No insurance policies configured yet.',
|
||||
policyName: 'Policy name',
|
||||
chargeValue: 'Charge value',
|
||||
required: 'Required',
|
||||
addPolicy: 'Add policy',
|
||||
noRules: 'No pricing rules configured yet.',
|
||||
ruleName: 'Rule name',
|
||||
addRule: 'Add rule',
|
||||
reportingPeriod: 'Reporting period',
|
||||
reportFormat: 'Report format',
|
||||
accountantName: 'Accountant name',
|
||||
accountantEmail: 'Accountant email',
|
||||
autoSend: 'Auto-send reports to the accountant',
|
||||
amanpayMerchantId: 'AmanPay merchant ID',
|
||||
amanpaySecretKey: 'AmanPay secret key',
|
||||
paypalEmail: 'PayPal business email',
|
||||
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Paramètres avancés',
|
||||
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
|
||||
save: 'Enregistrement…',
|
||||
saveBrand: 'Enregistrer la marque',
|
||||
brandProfile: 'Marque et profil public',
|
||||
brandProfileDesc: 'Contrôlez l’affichage de votre entreprise sur la marketplace et le site de réservation.',
|
||||
payments: 'Méthodes de paiement location',
|
||||
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
|
||||
savePayments: 'Enregistrer les paiements',
|
||||
customDomain: 'Domaine personnalisé',
|
||||
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
|
||||
saveDomain: 'Enregistrer le domaine',
|
||||
removeDomain: 'Supprimer le domaine',
|
||||
contractPolicies: 'Contrat et politiques conducteur',
|
||||
savePolicies: 'Enregistrer les politiques',
|
||||
insurancePolicies: 'Polices d’assurance',
|
||||
pricingRules: 'Règles tarifaires',
|
||||
accounting: 'Comptabilité et exports',
|
||||
saveAccounting: 'Enregistrer la comptabilité',
|
||||
displayName: 'Nom affiché',
|
||||
tagline: 'Slogan',
|
||||
publicEmail: 'E-mail public',
|
||||
publicPhone: 'Téléphone public',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
websiteUrl: 'URL du site',
|
||||
contractLanguage: 'Langue du contrat',
|
||||
whatsapp: 'Numéro WhatsApp',
|
||||
brandColor: 'Couleur de marque',
|
||||
listedMarketplace: 'Publié sur la marketplace',
|
||||
logoUpload: 'Logo',
|
||||
heroUpload: 'Image principale',
|
||||
uploading: 'Téléversement…',
|
||||
logoUploaded: 'Logo téléversé',
|
||||
noLogo: 'Aucun logo téléversé',
|
||||
heroUploaded: 'Image principale téléversée',
|
||||
noHero: 'Aucune image principale téléversée',
|
||||
subdomain: 'Sous-domaine',
|
||||
customDomainLabel: 'Domaine personnalisé',
|
||||
domainPlaceholder: 'voitures.exemple.com',
|
||||
status: 'Statut',
|
||||
verified: 'Vérifié',
|
||||
pendingDns: 'Vérification DNS en attente',
|
||||
notConfigured: 'Non configuré',
|
||||
fuelPolicyType: 'Type de politique carburant',
|
||||
additionalDriverCharge: 'Supplément conducteur additionnel',
|
||||
dailyDriverRate: 'Tarif conducteur/jour',
|
||||
flatDriverRate: 'Tarif conducteur fixe',
|
||||
fuelPolicyNote: 'Note sur la politique carburant',
|
||||
damagePolicy: 'Politique dommages',
|
||||
active: 'Actif',
|
||||
inactive: 'Inactif',
|
||||
noInsurance: 'Aucune police d’assurance configurée.',
|
||||
policyName: 'Nom de la police',
|
||||
chargeValue: 'Valeur de facturation',
|
||||
required: 'Obligatoire',
|
||||
addPolicy: 'Ajouter la police',
|
||||
noRules: 'Aucune règle tarifaire configurée.',
|
||||
ruleName: 'Nom de la règle',
|
||||
addRule: 'Ajouter la règle',
|
||||
reportingPeriod: 'Période de reporting',
|
||||
reportFormat: 'Format de rapport',
|
||||
accountantName: 'Nom du comptable',
|
||||
accountantEmail: 'E-mail du comptable',
|
||||
autoSend: 'Envoyer automatiquement les rapports au comptable',
|
||||
amanpayMerchantId: 'ID marchand AmanPay',
|
||||
amanpaySecretKey: 'Clé secrète AmanPay',
|
||||
paypalEmail: 'Email business PayPal',
|
||||
noPaymentConfigured: 'Si aucun moyen de paiement n’est configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الإعدادات المتقدمة',
|
||||
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
|
||||
save: 'جارٍ الحفظ…',
|
||||
saveBrand: 'حفظ العلامة',
|
||||
brandProfile: 'العلامة والملف العام',
|
||||
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
|
||||
payments: 'طرق دفع الإيجار',
|
||||
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
|
||||
savePayments: 'حفظ الدفع',
|
||||
customDomain: 'نطاق مخصص',
|
||||
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
|
||||
saveDomain: 'حفظ النطاق',
|
||||
removeDomain: 'إزالة النطاق المخصص',
|
||||
contractPolicies: 'العقد وسياسات السائق',
|
||||
savePolicies: 'حفظ السياسات',
|
||||
insurancePolicies: 'سياسات التأمين',
|
||||
pricingRules: 'قواعد التسعير',
|
||||
accounting: 'المحاسبة والتصدير',
|
||||
saveAccounting: 'حفظ المحاسبة',
|
||||
displayName: 'اسم العرض',
|
||||
tagline: 'الشعار',
|
||||
publicEmail: 'البريد الإلكتروني العام',
|
||||
publicPhone: 'الهاتف العام',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
websiteUrl: 'رابط الموقع',
|
||||
contractLanguage: 'لغة العقد',
|
||||
whatsapp: 'رقم واتساب',
|
||||
brandColor: 'لون العلامة',
|
||||
listedMarketplace: 'مدرج في السوق',
|
||||
logoUpload: 'رفع الشعار',
|
||||
heroUpload: 'رفع صورة الواجهة',
|
||||
uploading: 'جارٍ الرفع…',
|
||||
logoUploaded: 'تم رفع الشعار',
|
||||
noLogo: 'لم يتم رفع شعار بعد',
|
||||
heroUploaded: 'تم رفع صورة الواجهة',
|
||||
noHero: 'لم يتم رفع صورة واجهة بعد',
|
||||
subdomain: 'النطاق الفرعي',
|
||||
customDomainLabel: 'النطاق المخصص',
|
||||
domainPlaceholder: 'cars.example.com',
|
||||
status: 'الحالة',
|
||||
verified: 'موثق',
|
||||
pendingDns: 'بانتظار التحقق من DNS',
|
||||
notConfigured: 'غير مُعدّ',
|
||||
fuelPolicyType: 'نوع سياسة الوقود',
|
||||
additionalDriverCharge: 'رسوم السائق الإضافي',
|
||||
dailyDriverRate: 'تعرفة السائق اليومية',
|
||||
flatDriverRate: 'تعرفة السائق الثابتة',
|
||||
fuelPolicyNote: 'ملاحظة سياسة الوقود',
|
||||
damagePolicy: 'سياسة الأضرار',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
|
||||
policyName: 'اسم السياسة',
|
||||
chargeValue: 'قيمة الرسوم',
|
||||
required: 'إلزامي',
|
||||
addPolicy: 'إضافة سياسة',
|
||||
noRules: 'لا توجد قواعد تسعير مهيأة.',
|
||||
ruleName: 'اسم القاعدة',
|
||||
addRule: 'إضافة قاعدة',
|
||||
reportingPeriod: 'فترة التقرير',
|
||||
reportFormat: 'تنسيق التقرير',
|
||||
accountantName: 'اسم المحاسب',
|
||||
accountantEmail: 'بريد المحاسب',
|
||||
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
|
||||
amanpayMerchantId: 'معرّف التاجر AmanPay',
|
||||
amanpaySecretKey: 'المفتاح السري AmanPay',
|
||||
paypalEmail: 'بريد أعمال PayPal',
|
||||
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
const [brand, setBrand] = useState<BrandSettings | null>(null)
|
||||
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
|
||||
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
|
||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
|
||||
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
|
||||
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
|
||||
const [newRule, setNewRule] = useState(emptyRule)
|
||||
const [customDomain, setCustomDomain] = useState('')
|
||||
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
|
||||
apiFetch<BrandSettings | null>('/companies/me/brand'),
|
||||
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
|
||||
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
|
||||
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
|
||||
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
|
||||
])
|
||||
setBrand(brandData)
|
||||
setCustomDomain(brandData?.customDomain ?? '')
|
||||
setContractSettings(contractData ?? {
|
||||
fuelPolicyType: 'FULL_TO_FULL',
|
||||
fuelPolicyNote: '',
|
||||
additionalDriverCharge: 'FREE',
|
||||
additionalDriverDailyRate: 0,
|
||||
additionalDriverFlatRate: 0,
|
||||
damagePolicy: '',
|
||||
})
|
||||
setInsurancePolicies(insuranceData)
|
||||
setPricingRules(ruleData)
|
||||
setAccountingSettings(accountingData ?? {
|
||||
reportingPeriod: 'MONTHLY',
|
||||
accountantEmail: '',
|
||||
accountantName: '',
|
||||
autoSendReport: false,
|
||||
reportFormat: 'CSV',
|
||||
})
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load settings')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBrandSettings() {
|
||||
if (!brand) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
displayName: brand.displayName,
|
||||
tagline: brand.tagline || undefined,
|
||||
primaryColor: brand.primaryColor,
|
||||
accentColor: brand.accentColor || undefined,
|
||||
publicEmail: brand.publicEmail || undefined,
|
||||
publicPhone: brand.publicPhone || undefined,
|
||||
publicAddress: brand.publicAddress || undefined,
|
||||
publicCity: brand.publicCity || undefined,
|
||||
publicCountry: brand.publicCountry || undefined,
|
||||
websiteUrl: brand.websiteUrl || undefined,
|
||||
defaultLocale: brand.defaultLocale || undefined,
|
||||
whatsappNumber: brand.whatsappNumber || undefined,
|
||||
paypalEmail: brand.paypalEmail || undefined,
|
||||
amanpayMerchantId: brand.amanpayMerchantId || undefined,
|
||||
amanpaySecretKey: brand.amanpaySecretKey || undefined,
|
||||
paypalMerchantId: brand.paypalMerchantId || undefined,
|
||||
defaultCurrency: brand.defaultCurrency || undefined,
|
||||
isListedOnMarketplace: brand.isListedOnMarketplace,
|
||||
}),
|
||||
})
|
||||
setBrand(updated)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save brand settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
|
||||
if (!file) return
|
||||
setUploadingAsset(kind)
|
||||
setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
setBrand(updated)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? `Failed to upload ${kind}`)
|
||||
} finally {
|
||||
setUploadingAsset(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomDomain() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand/custom-domain', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ customDomain }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save custom domain')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCustomDomain() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/brand/custom-domain', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
setCustomDomain('')
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to remove custom domain')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function saveContractSettings() {
|
||||
if (!contractSettings) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/contract-settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(contractSettings),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save contract settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccountingSettings() {
|
||||
if (!accountingSettings) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/accounting-settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(accountingSettings),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save accounting settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createInsurance() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/insurance-policies', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newInsurance),
|
||||
})
|
||||
setNewInsurance(emptyInsurance)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to create insurance policy')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleInsurance(policy: InsurancePolicy) {
|
||||
try {
|
||||
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !policy.isActive }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update insurance policy')
|
||||
}
|
||||
}
|
||||
|
||||
async function createRule() {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/companies/me/pricing-rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newRule),
|
||||
})
|
||||
setNewRule(emptyRule)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to create pricing rule')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRule(rule: PricingRule) {
|
||||
try {
|
||||
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !rule.isActive }),
|
||||
})
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update pricing rule')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rdg-page-heading">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
|
||||
{brand && (
|
||||
<div className="grid gap-6">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.save : copy.saveBrand}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
|
||||
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
|
||||
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
|
||||
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
|
||||
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
|
||||
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
|
||||
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
|
||||
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.contractLanguage}</label>
|
||||
<select className="input-field" value={brand.defaultLocale ?? 'en'} onChange={(event) => setBrand((current) => current ? { ...current, defaultLocale: event.target.value } : current)}>
|
||||
<option value="en">EN</option>
|
||||
<option value="fr">FR</option>
|
||||
<option value="ar">AR</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
|
||||
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
|
||||
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
|
||||
{copy.listedMarketplace}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.save : copy.savePayments}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
|
||||
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
|
||||
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
|
||||
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
{copy.noPaymentConfigured}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
|
||||
{saving ? copy.save : copy.saveDomain}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
|
||||
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
|
||||
</div>
|
||||
{brand.customDomain ? (
|
||||
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
|
||||
{copy.removeDomain}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contractSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
|
||||
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.save : copy.savePolicies}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
|
||||
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
|
||||
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
|
||||
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
|
||||
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{insurancePolicies.map((policy) => (
|
||||
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{policy.name}</p>
|
||||
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{policy.isActive ? copy.active : copy.inactive}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
|
||||
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
|
||||
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{pricingRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{rule.name}</p>
|
||||
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{rule.isActive ? copy.active : copy.inactive}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
|
||||
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
|
||||
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
|
||||
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accountingSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
|
||||
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? copy.save : copy.saveAccounting}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
|
||||
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
|
||||
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
|
||||
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
|
||||
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
|
||||
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
|
||||
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
|
||||
{copy.autoSend}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user