@@ -0,0 +1,848 @@
'use client'
import { useEffect , useState } from 'react'
import { CalendarRange , History , PlusCircle , Save , Sparkles , Trash2 } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface PricingConfiguration {
id : string
vehicleId : string
pricingMode : 'MANUAL' | 'AUTOMATIC'
baseDailyRate : number
weeklyRate : number | null
weekendRate : number | null
holidayRate : number | null
monthlyRate : number | null
longTermDailyRate : number | null
minimumDailyRate : number | null
maximumDailyRate : number | null
maxDailyPriceMovementPct : number
automaticPricingEnabled : boolean
approvalRequired : boolean
createdAt : string
updatedAt : string
}
interface PricingRule {
id : string
name : string
ruleType : 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
startDate : string
endDate : string
dailyRate : number | null
weeklyRate : number | null
weekendRate : number | null
monthlyRate : number | null
minDailyRate : number | null
maxDailyRate : number | null
automaticAdjustmentPct : number | null
priceAdjustment : number | null
isActive : boolean
sortOrder : number
createdAt : string
updatedAt : string
}
interface PricingHistoryEntry {
id : string
source : 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
changedByEmployeeId : string | null
previousDailyRate : number | null
nextDailyRate : number | null
previousWeeklyRate : number | null
nextWeeklyRate : number | null
note : string | null
effectiveFrom : string | null
createdAt : string
}
interface PricingPreviewItem {
date : string
dailyRate : number
pricingType : 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
matchedRuleName : string | null
}
interface PricingResponse {
configuration : PricingConfiguration
rules : PricingRule [ ]
history : PricingHistoryEntry [ ]
preview : {
today : PricingPreviewItem
next14Days : PricingPreviewItem [ ]
}
}
interface ConfigForm {
pricingMode : 'MANUAL' | 'AUTOMATIC'
baseDailyRate : string
weeklyRate : string
weekendRate : string
holidayRate : string
monthlyRate : string
longTermDailyRate : string
minimumDailyRate : string
maximumDailyRate : string
maxDailyPriceMovementPct : string
automaticPricingEnabled : boolean
approvalRequired : boolean
}
interface RuleForm {
id? : string
name : string
ruleType : PricingRule [ 'ruleType' ]
startDate : string
endDate : string
dailyRate : string
weeklyRate : string
weekendRate : string
monthlyRate : string
minDailyRate : string
maxDailyRate : string
automaticAdjustmentPct : string
priceAdjustment : string
isActive : boolean
sortOrder : string
}
function centsToInput ( value : number | null | undefined ) {
return value == null ? '' : ( value / 100 ) . toFixed ( 2 )
}
function inputToCents ( value : string ) {
if ( ! value . trim ( ) ) return null
const parsed = Number . parseFloat ( value )
if ( ! Number . isFinite ( parsed ) ) return null
return Math . round ( parsed * 100 )
}
function numericInputToInt ( value : string ) {
if ( ! value . trim ( ) ) return null
const parsed = Number . parseInt ( value , 10 )
return Number . isFinite ( parsed ) ? parsed : null
}
function toDateInput ( value : string ) {
return value . slice ( 0 , 10 )
}
function buildConfigForm ( configuration : PricingConfiguration ) : ConfigForm {
return {
pricingMode : configuration.pricingMode ,
baseDailyRate : centsToInput ( configuration . baseDailyRate ) ,
weeklyRate : centsToInput ( configuration . weeklyRate ) ,
weekendRate : centsToInput ( configuration . weekendRate ) ,
holidayRate : centsToInput ( configuration . holidayRate ) ,
monthlyRate : centsToInput ( configuration . monthlyRate ) ,
longTermDailyRate : centsToInput ( configuration . longTermDailyRate ) ,
minimumDailyRate : centsToInput ( configuration . minimumDailyRate ) ,
maximumDailyRate : centsToInput ( configuration . maximumDailyRate ) ,
maxDailyPriceMovementPct : String ( configuration . maxDailyPriceMovementPct ) ,
automaticPricingEnabled : configuration.automaticPricingEnabled ,
approvalRequired : configuration.approvalRequired ,
}
}
function buildRuleForm ( rule? : PricingRule ) : RuleForm {
const today = new Date ( ) . toISOString ( ) . slice ( 0 , 10 )
const nextWeek = new Date ( Date . now ( ) + 6 * 24 * 60 * 60 * 1000 ) . toISOString ( ) . slice ( 0 , 10 )
return {
id : rule?.id ,
name : rule?.name ? ? '' ,
ruleType : rule?.ruleType ? ? 'DATE_RANGE' ,
startDate : rule ? toDateInput ( rule . startDate ) : today ,
endDate : rule ? toDateInput ( rule . endDate ) : nextWeek ,
dailyRate : centsToInput ( rule ? . dailyRate ) ,
weeklyRate : centsToInput ( rule ? . weeklyRate ) ,
weekendRate : centsToInput ( rule ? . weekendRate ) ,
monthlyRate : centsToInput ( rule ? . monthlyRate ) ,
minDailyRate : centsToInput ( rule ? . minDailyRate ) ,
maxDailyRate : centsToInput ( rule ? . maxDailyRate ) ,
automaticAdjustmentPct : rule?.automaticAdjustmentPct != null ? String ( rule . automaticAdjustmentPct ) : '' ,
priceAdjustment : centsToInput ( rule ? . priceAdjustment ) ,
isActive : rule?.isActive ? ? true ,
sortOrder : rule?.sortOrder != null ? String ( rule . sortOrder ) : '0' ,
}
}
export default function VehiclePricingManager ( { vehicleId } : { vehicleId : string } ) {
const { language } = useDashboardI18n ( )
const copy = {
en : {
title : 'Pricing management' ,
subtitle : 'Manage fixed rates, automatic pricing bounds, seasonal rules, and pricing history for this vehicle.' ,
savePricing : 'Save pricing' ,
saving : 'Saving…' ,
loading : 'Loading pricing settings…' ,
mode : 'Pricing method' ,
manual : 'Manual fixed pricing' ,
automatic : 'Automatic dynamic pricing' ,
baseDailyRate : 'Base daily rate (MAD)' ,
weeklyRate : 'Weekly rate (MAD)' ,
weekendRate : 'Weekend rate (MAD)' ,
holidayRate : 'Holiday rate (MAD)' ,
monthlyRate : 'Monthly rate (MAD)' ,
longTermDailyRate : 'Long-term daily rate (MAD)' ,
minimumDailyRate : 'Minimum daily rate (MAD)' ,
maximumDailyRate : 'Maximum daily rate (MAD)' ,
automaticPricingEnabled : 'Automatic pricing enabled' ,
approvalRequired : 'Approve automatic price changes before they go live' ,
maxDailyMovement : 'Max daily price movement (%)' ,
currentPreview : 'Current pricing preview' ,
next14Days : 'Next 14 days' ,
matchedRule : 'Matched rule' ,
noRule : 'No rule' ,
rules : 'Seasonal and manual rules' ,
addRule : 'Add rule' ,
createRule : 'Create rule' ,
updateRule : 'Save rule' ,
deleting : 'Deleting…' ,
deleteRule : 'Delete' ,
noRules : 'No vehicle-specific pricing rules yet.' ,
history : 'Pricing history' ,
noHistory : 'No pricing changes recorded yet.' ,
active : 'Active' ,
inactive : 'Inactive' ,
startDate : 'Start date' ,
endDate : 'End date' ,
name : 'Rule name' ,
ruleType : 'Rule type' ,
adjustmentPct : 'Auto adjustment (%)' ,
priceAdjustment : 'Flat adjustment (MAD)' ,
sortOrder : 'Priority' ,
saveErrorBase : 'Base daily rate is required.' ,
saveErrorBounds : 'Minimum daily rate cannot be greater than maximum daily rate.' ,
saveErrorRule : 'Rules need a name plus at least one rate, bound, or adjustment.' ,
configSaved : 'Pricing settings saved.' ,
ruleSaved : 'Pricing rule saved.' ,
ruleCreated : 'Pricing rule created.' ,
ruleDeleted : 'Pricing rule deleted.' ,
previewLabels : {
MANUAL_BASE : 'Base manual price' ,
AUTOMATIC_BASE : 'Automatic base price' ,
WEEKEND_RATE : 'Weekend price' ,
RULE_RATE : 'Manual rule' ,
AUTOMATIC_RULE : 'Automatic rule' ,
} as Record < PricingPreviewItem [ 'pricingType' ] , string > ,
ruleTypeLabels : {
DATE_RANGE : 'Date range' ,
SEASONAL : 'Season' ,
HOLIDAY : 'Holiday' ,
WEEKEND : 'Weekend' ,
SPECIAL_EVENT : 'Special event' ,
MANUAL_OVERRIDE : 'Manual override' ,
} as Record < PricingRule [ 'ruleType' ] , string > ,
historyLabels : {
CONFIG_UPDATE : 'Configuration updated' ,
RULE_CREATED : 'Rule created' ,
RULE_UPDATED : 'Rule updated' ,
RULE_DELETED : 'Rule deleted' ,
MANUAL_OVERRIDE : 'Manual override' ,
} as Record < PricingHistoryEntry [ 'source' ] , string > ,
ruleHint : 'Use direct rates for fixed pricing or adjustments and bounds to guide automatic pricing.' ,
} ,
fr : {
title : 'Gestion des tarifs' ,
subtitle : 'Gérez les tarifs fixes, les limites automatiques, les saisons et l’ historique des prix de ce véhicule.' ,
savePricing : 'Enregistrer les tarifs' ,
saving : 'Enregistrement…' ,
loading : 'Chargement des paramètres tarifaires…' ,
mode : 'Méthode tarifaire' ,
manual : 'Tarification fixe manuelle' ,
automatic : 'Tarification dynamique automatique' ,
baseDailyRate : 'Tarif journalier de base (MAD)' ,
weeklyRate : 'Tarif hebdomadaire (MAD)' ,
weekendRate : 'Tarif week-end (MAD)' ,
holidayRate : 'Tarif jours fériés (MAD)' ,
monthlyRate : 'Tarif mensuel (MAD)' ,
longTermDailyRate : 'Tarif long séjour / jour (MAD)' ,
minimumDailyRate : 'Tarif journalier minimum (MAD)' ,
maximumDailyRate : 'Tarif journalier maximum (MAD)' ,
automaticPricingEnabled : 'Tarification automatique activée' ,
approvalRequired : 'Valider les changements automatiques avant publication' ,
maxDailyMovement : 'Variation maximale par jour (%)' ,
currentPreview : 'Aperçu des tarifs' ,
next14Days : '14 prochains jours' ,
matchedRule : 'Règle appliquée' ,
noRule : 'Aucune règle' ,
rules : 'Règles saisonnières et manuelles' ,
addRule : 'Ajouter une règle' ,
createRule : 'Créer la règle' ,
updateRule : 'Enregistrer la règle' ,
deleting : 'Suppression…' ,
deleteRule : 'Supprimer' ,
noRules : 'Aucune règle spécifique au véhicule pour le moment.' ,
history : 'Historique tarifaire' ,
noHistory : 'Aucun changement tarifaire enregistré.' ,
active : 'Active' ,
inactive : 'Inactive' ,
startDate : 'Date de début' ,
endDate : 'Date de fin' ,
name : 'Nom de la règle' ,
ruleType : 'Type de règle' ,
adjustmentPct : 'Ajustement auto (%)' ,
priceAdjustment : 'Ajustement fixe (MAD)' ,
sortOrder : 'Priorité' ,
saveErrorBase : 'Le tarif journalier de base est requis.' ,
saveErrorBounds : 'Le tarif minimum ne peut pas dépasser le maximum.' ,
saveErrorRule : 'Les règles nécessitent un nom et au moins un tarif, une limite ou un ajustement.' ,
configSaved : 'Paramètres tarifaires enregistrés.' ,
ruleSaved : 'Règle tarifaire enregistrée.' ,
ruleCreated : 'Règle tarifaire créée.' ,
ruleDeleted : 'Règle tarifaire supprimée.' ,
previewLabels : {
MANUAL_BASE : 'Tarif manuel de base' ,
AUTOMATIC_BASE : 'Base automatique' ,
WEEKEND_RATE : 'Tarif week-end' ,
RULE_RATE : 'Règle manuelle' ,
AUTOMATIC_RULE : 'Règle automatique' ,
} as Record < PricingPreviewItem [ 'pricingType' ] , string > ,
ruleTypeLabels : {
DATE_RANGE : 'Plage de dates' ,
SEASONAL : 'Saison' ,
HOLIDAY : 'Jour férié' ,
WEEKEND : 'Week-end' ,
SPECIAL_EVENT : 'Événement spécial' ,
MANUAL_OVERRIDE : 'Forçage manuel' ,
} as Record < PricingRule [ 'ruleType' ] , string > ,
historyLabels : {
CONFIG_UPDATE : 'Configuration mise à jour' ,
RULE_CREATED : 'Règle créée' ,
RULE_UPDATED : 'Règle mise à jour' ,
RULE_DELETED : 'Règle supprimée' ,
MANUAL_OVERRIDE : 'Forçage manuel' ,
} as Record < PricingHistoryEntry [ 'source' ] , string > ,
ruleHint : 'Utilisez des tarifs directs pour le fixe ou des ajustements et limites pour guider l’ automatique.' ,
} ,
ar : {
title : 'إدارة التسعير' ,
subtitle : 'إدارة الأسعار الثابتة وحدود التسعير التلقائي والقواعد الموسمية وسجل تغييرات السعر لهذه السيارة.' ,
savePricing : 'حفظ التسعير' ,
saving : 'جارٍ الحفظ…' ,
loading : 'جارٍ تحميل إعدادات التسعير…' ,
mode : 'طريقة التسعير' ,
manual : 'تسعير ثابت يدوي' ,
automatic : 'تسعير ديناميكي تلقائي' ,
baseDailyRate : 'السعر اليومي الأساسي (درهم)' ,
weeklyRate : 'السعر الأسبوعي (درهم)' ,
weekendRate : 'سعر عطلة نهاية الأسبوع (درهم)' ,
holidayRate : 'سعر العطل (درهم)' ,
monthlyRate : 'السعر الشهري (درهم)' ,
longTermDailyRate : 'سعر الإيجار الطويل / يوم (درهم)' ,
minimumDailyRate : 'الحد الأدنى للسعر اليومي (درهم)' ,
maximumDailyRate : 'الحد الأقصى للسعر اليومي (درهم)' ,
automaticPricingEnabled : 'تفعيل التسعير التلقائي' ,
approvalRequired : 'اعتماد تغييرات السعر التلقائية قبل نشرها' ,
maxDailyMovement : 'أقصى تغيير يومي (%)' ,
currentPreview : 'معاينة التسعير الحالية' ,
next14Days : 'الأيام الـ 14 القادمة' ,
matchedRule : 'القاعدة المطبقة' ,
noRule : 'لا توجد قاعدة' ,
rules : 'القواعد الموسمية واليدوية' ,
addRule : 'إضافة قاعدة' ,
createRule : 'إنشاء القاعدة' ,
updateRule : 'حفظ القاعدة' ,
deleting : 'جارٍ الحذف…' ,
deleteRule : 'حذف' ,
noRules : 'لا توجد قواعد تسعير خاصة بهذه السيارة بعد.' ,
history : 'سجل التسعير' ,
noHistory : 'لا توجد تغييرات تسعير مسجلة بعد.' ,
active : 'نشطة' ,
inactive : 'غير نشطة' ,
startDate : 'تاريخ البدء' ,
endDate : 'تاريخ الانتهاء' ,
name : 'اسم القاعدة' ,
ruleType : 'نوع القاعدة' ,
adjustmentPct : 'تعديل تلقائي (%)' ,
priceAdjustment : 'تعديل ثابت (درهم)' ,
sortOrder : 'الأولوية' ,
saveErrorBase : 'السعر اليومي الأساسي مطلوب.' ,
saveErrorBounds : 'لا يمكن أن يكون الحد الأدنى أعلى من الحد الأقصى.' ,
saveErrorRule : 'تحتاج القواعد إلى اسم وإلى سعر أو حد أو تعديل واحد على الأقل.' ,
configSaved : 'تم حفظ إعدادات التسعير.' ,
ruleSaved : 'تم حفظ قاعدة التسعير.' ,
ruleCreated : 'تم إنشاء قاعدة التسعير.' ,
ruleDeleted : 'تم حذف قاعدة التسعير.' ,
previewLabels : {
MANUAL_BASE : 'السعر اليدوي الأساسي' ,
AUTOMATIC_BASE : 'السعر الأساسي التلقائي' ,
WEEKEND_RATE : 'سعر عطلة نهاية الأسبوع' ,
RULE_RATE : 'قاعدة يدوية' ,
AUTOMATIC_RULE : 'قاعدة تلقائية' ,
} as Record < PricingPreviewItem [ 'pricingType' ] , string > ,
ruleTypeLabels : {
DATE_RANGE : 'نطاق تاريخ' ,
SEASONAL : 'موسم' ,
HOLIDAY : 'عطلة' ,
WEEKEND : 'نهاية أسبوع' ,
SPECIAL_EVENT : 'مناسبة خاصة' ,
MANUAL_OVERRIDE : 'تجاوز يدوي' ,
} as Record < PricingRule [ 'ruleType' ] , string > ,
historyLabels : {
CONFIG_UPDATE : 'تم تحديث الإعدادات' ,
RULE_CREATED : 'تم إنشاء قاعدة' ,
RULE_UPDATED : 'تم تحديث قاعدة' ,
RULE_DELETED : 'تم حذف قاعدة' ,
MANUAL_OVERRIDE : 'تجاوز يدوي' ,
} as Record < PricingHistoryEntry [ 'source' ] , string > ,
ruleHint : 'استخدم أسعاراً مباشرة للتسعير الثابت أو تعديلات وحدوداً لتوجيه التسعير التلقائي.' ,
} ,
} [ language ]
const [ loading , setLoading ] = useState ( true )
const [ savingConfig , setSavingConfig ] = useState ( false )
const [ savingRuleId , setSavingRuleId ] = useState < string | null > ( null )
const [ deletingRuleId , setDeletingRuleId ] = useState < string | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const [ success , setSuccess ] = useState < string | null > ( null )
const [ configForm , setConfigForm ] = useState < ConfigForm | null > ( null )
const [ rules , setRules ] = useState < RuleForm [ ] > ( [ ] )
const [ newRule , setNewRule ] = useState < RuleForm > ( buildRuleForm ( ) )
const [ history , setHistory ] = useState < PricingHistoryEntry [ ] > ( [ ] )
const [ preview , setPreview ] = useState < PricingResponse [ 'preview' ] | null > ( null )
function applyResponse ( data : PricingResponse ) {
setConfigForm ( buildConfigForm ( data . configuration ) )
setRules ( data . rules . map ( ( rule ) = > buildRuleForm ( rule ) ) )
setHistory ( data . history )
setPreview ( data . preview )
}
async function load() {
setLoading ( true )
try {
const data = await apiFetch < PricingResponse > ( ` /vehicles/ ${ vehicleId } /pricing ` )
applyResponse ( data )
setError ( null )
} catch ( err : any ) {
setError ( err . message ? ? 'Failed to load vehicle pricing' )
} finally {
setLoading ( false )
}
}
useEffect ( ( ) = > {
load ( )
} , [ vehicleId ] )
async function saveConfiguration() {
if ( ! configForm ) return
const baseDailyRate = inputToCents ( configForm . baseDailyRate )
const minimumDailyRate = inputToCents ( configForm . minimumDailyRate )
const maximumDailyRate = inputToCents ( configForm . maximumDailyRate )
if ( baseDailyRate == null ) {
setError ( copy . saveErrorBase )
return
}
if ( minimumDailyRate != null && maximumDailyRate != null && minimumDailyRate > maximumDailyRate ) {
setError ( copy . saveErrorBounds )
return
}
setSavingConfig ( true )
setError ( null )
setSuccess ( null )
try {
const data = await apiFetch < PricingResponse > ( ` /vehicles/ ${ vehicleId } /pricing ` , {
method : 'PUT' ,
body : JSON.stringify ( {
pricingMode : configForm.pricingMode ,
baseDailyRate ,
weeklyRate : inputToCents ( configForm . weeklyRate ) ,
weekendRate : inputToCents ( configForm . weekendRate ) ,
holidayRate : inputToCents ( configForm . holidayRate ) ,
monthlyRate : inputToCents ( configForm . monthlyRate ) ,
longTermDailyRate : inputToCents ( configForm . longTermDailyRate ) ,
minimumDailyRate ,
maximumDailyRate ,
maxDailyPriceMovementPct : numericInputToInt ( configForm . maxDailyPriceMovementPct ) ? ? 10 ,
automaticPricingEnabled : configForm.automaticPricingEnabled ,
approvalRequired : configForm.approvalRequired ,
note : copy.configSaved ,
} ) ,
} )
applyResponse ( data )
setSuccess ( copy . configSaved )
} catch ( err : any ) {
setError ( err . message ? ? copy . saveErrorBase )
} finally {
setSavingConfig ( false )
}
}
async function saveRule ( rule : RuleForm , create = false ) {
const hasPricingEffect = [
inputToCents ( rule . dailyRate ) ,
inputToCents ( rule . weeklyRate ) ,
inputToCents ( rule . weekendRate ) ,
inputToCents ( rule . monthlyRate ) ,
inputToCents ( rule . minDailyRate ) ,
inputToCents ( rule . maxDailyRate ) ,
numericInputToInt ( rule . automaticAdjustmentPct ) ,
inputToCents ( rule . priceAdjustment ) ,
] . some ( ( value ) = > value != null )
if ( ! rule . name . trim ( ) || ! hasPricingEffect ) {
setError ( copy . saveErrorRule )
return
}
const payload = {
name : rule.name.trim ( ) ,
ruleType : rule.ruleType ,
startDate : rule.startDate ,
endDate : rule.endDate ,
dailyRate : inputToCents ( rule . dailyRate ) ,
weeklyRate : inputToCents ( rule . weeklyRate ) ,
weekendRate : inputToCents ( rule . weekendRate ) ,
monthlyRate : inputToCents ( rule . monthlyRate ) ,
minDailyRate : inputToCents ( rule . minDailyRate ) ,
maxDailyRate : inputToCents ( rule . maxDailyRate ) ,
automaticAdjustmentPct : numericInputToInt ( rule . automaticAdjustmentPct ) ,
priceAdjustment : inputToCents ( rule . priceAdjustment ) ,
isActive : rule.isActive ,
sortOrder : numericInputToInt ( rule . sortOrder ) ? ? 0 ,
}
setSavingRuleId ( rule . id ? ? 'new' )
setError ( null )
setSuccess ( null )
try {
const data = create
? await apiFetch < PricingResponse > ( ` /vehicles/ ${ vehicleId } /pricing/rules ` , { method : 'POST' , body : JSON.stringify ( payload ) } )
: await apiFetch < PricingResponse > ( ` /vehicles/ ${ vehicleId } /pricing/rules/ ${ rule . id } ` , { method : 'PATCH' , body : JSON.stringify ( payload ) } )
applyResponse ( data )
if ( create ) {
setNewRule ( buildRuleForm ( ) )
setSuccess ( copy . ruleCreated )
} else {
setSuccess ( copy . ruleSaved )
}
} catch ( err : any ) {
setError ( err . message ? ? copy . saveErrorRule )
} finally {
setSavingRuleId ( null )
}
}
async function deleteRule ( ruleId : string ) {
setDeletingRuleId ( ruleId )
setError ( null )
setSuccess ( null )
try {
const data = await apiFetch < PricingResponse > ( ` /vehicles/ ${ vehicleId } /pricing/rules/ ${ ruleId } ` , { method : 'DELETE' } )
applyResponse ( data )
setSuccess ( copy . ruleDeleted )
} catch ( err : any ) {
setError ( err . message ? ? copy . ruleDeleted )
} finally {
setDeletingRuleId ( null )
}
}
if ( loading || ! configForm || ! preview ) {
return < div className = "card p-6 text-sm text-slate-500" > { copy . loading } < / div >
}
return (
< div className = "space-y-6" >
< div className = "card p-6" >
< div className = "flex flex-wrap items-start justify-between gap-4" >
< div >
< h3 className = "text-base font-semibold text-slate-900" > { copy . title } < / h3 >
< p className = "mt-1 max-w-3xl text-sm text-slate-500" > { copy . subtitle } < / p >
< / div >
< button onClick = { saveConfiguration } disabled = { savingConfig } className = "btn-primary" >
< Save className = "h-4 w-4" / >
{ savingConfig ? copy.saving : copy.savePricing }
< / button >
< / div >
{ error && < div className = "mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" > { error } < / div > }
{ success && < div className = "mt-4 rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700" > { success } < / div > }
< div className = "mt-6 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]" >
< div className = "grid gap-4 rounded-2xl border border-slate-200 bg-slate-50 p-4 md:grid-cols-2" >
< div className = "md:col-span-2" >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . mode } < / label >
< select className = "input-field" value = { configForm . pricingMode } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , pricingMode : event.target.value as ConfigForm [ 'pricingMode' ] } : current ) } >
< option value = "MANUAL" > { copy . manual } < / option >
< option value = "AUTOMATIC" > { copy . automatic } < / option >
< / select >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . baseDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . baseDailyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , baseDailyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weeklyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . weeklyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , weeklyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weekendRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . weekendRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , weekendRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . holidayRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . holidayRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , holidayRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . monthlyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . monthlyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , monthlyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . longTermDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . longTermDailyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , longTermDailyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . minimumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . minimumDailyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , minimumDailyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . maximumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { configForm . maximumDailyRate } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , maximumDailyRate : event.target.value } : current ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . maxDailyMovement } < / label >
< input className = "input-field" type = "number" min = "0" max = "100" step = "1" value = { configForm . maxDailyPriceMovementPct } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , maxDailyPriceMovementPct : event.target.value } : current ) } / >
< / div >
< div className = "md:col-span-2 flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4" >
< label className = "flex items-center gap-3 text-sm font-medium text-slate-700" >
< input type = "checkbox" checked = { configForm . automaticPricingEnabled } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , automaticPricingEnabled : event.target.checked } : current ) } / >
{ copy . automaticPricingEnabled }
< / label >
< label className = "flex items-center gap-3 text-sm font-medium text-slate-700" >
< input type = "checkbox" checked = { configForm . approvalRequired } onChange = { ( event ) = > setConfigForm ( ( current ) = > current ? { . . . current , approvalRequired : event.target.checked } : current ) } / >
{ copy . approvalRequired }
< / label >
< / div >
< / div >
< div className = "rounded-2xl border border-slate-200 bg-white p-5" >
< div className = "flex items-center gap-2" >
< Sparkles className = "h-4 w-4 text-blue-600" / >
< h4 className = "text-sm font-semibold text-slate-900" > { copy . currentPreview } < / h4 >
< / div >
< div className = "mt-4 rounded-2xl bg-slate-50 p-4" >
< p className = "text-xs uppercase tracking-wide text-slate-500" > { preview . today . date } < / p >
< p className = "mt-2 text-2xl font-semibold text-slate-900" > { formatCurrency ( preview . today . dailyRate , 'MAD' ) } < / p >
< p className = "mt-1 text-sm text-slate-500" > { copy . previewLabels [ preview . today . pricingType ] } < / p >
< p className = "mt-2 text-xs text-slate-400" > { copy . matchedRule } : { preview . today . matchedRuleName ? ? copy . noRule } < / p >
< / div >
< div className = "mt-4" >
< div className = "mb-3 flex items-center gap-2" >
< CalendarRange className = "h-4 w-4 text-slate-400" / >
< p className = "text-sm font-semibold text-slate-900" > { copy . next14Days } < / p >
< / div >
< div className = "space-y-2" >
{ preview . next14Days . map ( ( item ) = > (
< div key = { item . date } className = "flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2 text-sm" >
< div >
< p className = "font-medium text-slate-900" > { item . date } < / p >
< p className = "text-xs text-slate-500" > { copy . previewLabels [ item . pricingType ] } < / p >
< / div >
< div className = "text-right" >
< p className = "font-semibold text-slate-900" > { formatCurrency ( item . dailyRate , 'MAD' ) } < / p >
< p className = "text-xs text-slate-400" > { item . matchedRuleName ? ? copy . noRule } < / p >
< / div >
< / div >
) ) }
< / div >
< / div >
< / div >
< / div >
< / div >
< div className = "card p-6" >
< h3 className = "text-base font-semibold text-slate-900" > { copy . rules } < / h3 >
< p className = "mt-1 text-sm text-slate-500" > { copy . ruleHint } < / p >
< div className = "mt-5 space-y-4" >
{ rules . map ( ( rule , index ) = > (
< div key = { rule . id } className = "rounded-2xl border border-slate-200 bg-slate-50 p-4" >
< div className = "grid gap-4 lg:grid-cols-2" >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . name } < / label >
< input className = "input-field" value = { rule . name } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , name : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . ruleType } < / label >
< select className = "input-field" value = { rule . ruleType } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , ruleType : event.target.value as RuleForm [ 'ruleType' ] } : entry ) ) } >
{ Object . entries ( copy . ruleTypeLabels ) . map ( ( [ value , label ] ) = > (
< option key = { value } value = { value } > { label } < / option >
) ) }
< / select >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . startDate } < / label >
< input className = "input-field" type = "date" value = { rule . startDate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , startDate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . endDate } < / label >
< input className = "input-field" type = "date" value = { rule . endDate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , endDate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . baseDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . dailyRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , dailyRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weeklyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . weeklyRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , weeklyRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weekendRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . weekendRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , weekendRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . monthlyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . monthlyRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , monthlyRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . minimumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . minDailyRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , minDailyRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . maximumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { rule . maxDailyRate } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , maxDailyRate : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . adjustmentPct } < / label >
< input className = "input-field" type = "number" step = "1" value = { rule . automaticAdjustmentPct } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , automaticAdjustmentPct : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . priceAdjustment } < / label >
< input className = "input-field" type = "number" step = "0.01" value = { rule . priceAdjustment } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , priceAdjustment : event.target.value } : entry ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . sortOrder } < / label >
< input className = "input-field" type = "number" min = "0" step = "1" value = { rule . sortOrder } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , sortOrder : event.target.value } : entry ) ) } / >
< / div >
< div className = "flex items-end" >
< label className = "flex items-center gap-3 text-sm font-medium text-slate-700" >
< input type = "checkbox" checked = { rule . isActive } onChange = { ( event ) = > setRules ( ( current ) = > current . map ( ( entry , entryIndex ) = > entryIndex === index ? { . . . entry , isActive : event.target.checked } : entry ) ) } / >
{ rule . isActive ? copy.active : copy.inactive }
< / label >
< / div >
< / div >
< div className = "mt-4 flex flex-wrap items-center justify-end gap-2" >
< button onClick = { ( ) = > deleteRule ( rule . id ! ) } disabled = { deletingRuleId === rule . id } className = "btn-secondary text-red-600" >
< Trash2 className = "h-4 w-4" / >
{ deletingRuleId === rule . id ? copy.deleting : copy.deleteRule }
< / button >
< button onClick = { ( ) = > saveRule ( rule ) } disabled = { savingRuleId === rule . id } className = "btn-primary" >
< Save className = "h-4 w-4" / >
{ savingRuleId === rule . id ? copy.saving : copy.updateRule }
< / button >
< / div >
< / div >
) ) }
{ rules . length === 0 && < div className = "rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400" > { copy . noRules } < / div > }
< / div >
< div className = "mt-6 rounded-2xl border border-blue-100 bg-blue-50/60 p-4" >
< div className = "mb-4 flex items-center gap-2" >
< PlusCircle className = "h-4 w-4 text-blue-600" / >
< h4 className = "text-sm font-semibold text-slate-900" > { copy . addRule } < / h4 >
< / div >
< div className = "grid gap-4 lg:grid-cols-2" >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . name } < / label >
< input className = "input-field" value = { newRule . name } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , name : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . ruleType } < / label >
< select className = "input-field" value = { newRule . ruleType } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , ruleType : event.target.value as RuleForm [ 'ruleType' ] } ) ) } >
{ Object . entries ( copy . ruleTypeLabels ) . map ( ( [ value , label ] ) = > (
< option key = { value } value = { value } > { label } < / option >
) ) }
< / select >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . startDate } < / label >
< input className = "input-field" type = "date" value = { newRule . startDate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , startDate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . endDate } < / label >
< input className = "input-field" type = "date" value = { newRule . endDate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , endDate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . baseDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . dailyRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , dailyRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weeklyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . weeklyRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , weeklyRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . weekendRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . weekendRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , weekendRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . monthlyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . monthlyRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , monthlyRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . minimumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . minDailyRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , minDailyRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . maximumDailyRate } < / label >
< input className = "input-field" type = "number" min = "0" step = "0.01" value = { newRule . maxDailyRate } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , maxDailyRate : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . adjustmentPct } < / label >
< input className = "input-field" type = "number" step = "1" value = { newRule . automaticAdjustmentPct } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , automaticAdjustmentPct : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . priceAdjustment } < / label >
< input className = "input-field" type = "number" step = "0.01" value = { newRule . priceAdjustment } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , priceAdjustment : event.target.value } ) ) } / >
< / div >
< div >
< label className = "mb-1.5 block text-sm font-medium text-slate-700" > { copy . sortOrder } < / label >
< input className = "input-field" type = "number" min = "0" step = "1" value = { newRule . sortOrder } onChange = { ( event ) = > setNewRule ( ( current ) = > ( { . . . current , sortOrder : event.target.value } ) ) } / >
< / div >
< / div >
< div className = "mt-4 flex justify-end" >
< button onClick = { ( ) = > saveRule ( newRule , true ) } disabled = { savingRuleId === 'new' } className = "btn-primary" >
< PlusCircle className = "h-4 w-4" / >
{ savingRuleId === 'new' ? copy.saving : copy.createRule }
< / button >
< / div >
< / div >
< / div >
< div className = "card p-6" >
< div className = "flex items-center gap-2" >
< History className = "h-4 w-4 text-slate-400" / >
< h3 className = "text-base font-semibold text-slate-900" > { copy . history } < / h3 >
< / div >
< div className = "mt-5 space-y-3" >
{ history . map ( ( entry ) = > (
< div key = { entry . id } className = "rounded-2xl border border-slate-200 px-4 py-3 text-sm" >
< div className = "flex flex-wrap items-center justify-between gap-3" >
< div >
< p className = "font-medium text-slate-900" > { copy . historyLabels [ entry . source ] } < / p >
< p className = "text-slate-500" > { new Date ( entry . createdAt ) . toLocaleString ( ) } < / p >
< / div >
< div className = "text-right" >
{ entry . nextDailyRate != null && < p className = "font-semibold text-slate-900" > { formatCurrency ( entry . nextDailyRate , 'MAD' ) } < / p > }
{ entry . previousDailyRate != null && entry . nextDailyRate != null && (
< p className = "text-xs text-slate-400" > { formatCurrency ( entry . previousDailyRate , 'MAD' ) } → { formatCurrency ( entry . nextDailyRate , 'MAD' ) } < / p >
) }
< / div >
< / div >
{ entry . note && < p className = "mt-2 text-sm text-slate-600" > { entry . note } < / p > }
< / div >
) ) }
{ history . length === 0 && < div className = "rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm text-slate-400" > { copy . noHistory } < / div > }
< / div >
< / div >
< / div >
)
}