add pricing feature
This commit is contained in:
@@ -89,3 +89,54 @@ export async function findMaintenanceLogs(vehicleId: string) {
|
|||||||
export async function createMaintenanceLog(data: any) {
|
export async function createMaintenanceLog(data: any) {
|
||||||
return prisma.maintenanceLog.create({ data })
|
return prisma.maintenanceLog.create({ data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findPricingConfiguration(vehicleId: string) {
|
||||||
|
return prisma.vehiclePricingConfiguration.findUnique({
|
||||||
|
where: { vehicleId },
|
||||||
|
include: {
|
||||||
|
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||||
|
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPricingConfiguration(data: any) {
|
||||||
|
return prisma.vehiclePricingConfiguration.create({
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||||
|
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePricingConfiguration(id: string, data: any) {
|
||||||
|
return prisma.vehiclePricingConfiguration.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
rules: { orderBy: [{ sortOrder: 'asc' }, { startDate: 'asc' }, { createdAt: 'asc' }] },
|
||||||
|
history: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findPricingRule(id: string, configurationId: string) {
|
||||||
|
return prisma.vehiclePricingRule.findFirst({ where: { id, configurationId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPricingRule(data: any) {
|
||||||
|
return prisma.vehiclePricingRule.create({ data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePricingRule(id: string, data: any) {
|
||||||
|
return prisma.vehiclePricingRule.update({ where: { id }, data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePricingRule(id: string, configurationId: string) {
|
||||||
|
return prisma.vehiclePricingRule.deleteMany({ where: { id, configurationId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPriceHistory(data: any) {
|
||||||
|
return prisma.vehiclePriceHistory.create({ data })
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import * as service from './vehicle.service'
|
|||||||
import {
|
import {
|
||||||
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
|
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
|
||||||
calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema,
|
calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema,
|
||||||
idParamSchema, photoIdxSchema, blockIdParamSchema,
|
pricingConfigSchema, pricingRuleSchema,
|
||||||
|
idParamSchema, photoIdxSchema, blockIdParamSchema, pricingRuleParamSchema,
|
||||||
} from './vehicle.schemas'
|
} from './vehicle.schemas'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
@@ -145,4 +146,42 @@ router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) =
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/:id/pricing', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = parseParams(idParamSchema, req)
|
||||||
|
ok(res, await service.getVehiclePricing(id, req.companyId))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id/pricing', requireRole('MANAGER'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = parseParams(idParamSchema, req)
|
||||||
|
const body = parseBody(pricingConfigSchema, req)
|
||||||
|
ok(res, await service.updateVehiclePricing(id, req.companyId, req.employee.id, body))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/:id/pricing/rules', requireRole('MANAGER'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = parseParams(idParamSchema, req)
|
||||||
|
const body = parseBody(pricingRuleSchema, req)
|
||||||
|
created(res, await service.createVehiclePricingRule(id, req.companyId, req.employee.id, body))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.patch('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
|
||||||
|
const body = parseBody(pricingRuleSchema.partial(), req)
|
||||||
|
ok(res, await service.updateVehiclePricingRule(id, req.companyId, ruleId, req.employee.id, body))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id/pricing/rules/:ruleId', requireRole('MANAGER'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id, ruleId } = parseParams(pricingRuleParamSchema, req)
|
||||||
|
ok(res, await service.deleteVehiclePricingRule(id, req.companyId, ruleId, req.employee.id))
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export const vehicleSchema = z.object({
|
|||||||
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
|
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
|
||||||
fuelType: z.enum(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']).default('GASOLINE'),
|
fuelType: z.enum(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']).default('GASOLINE'),
|
||||||
features: z.array(z.string()).default([]),
|
features: z.array(z.string()).default([]),
|
||||||
dailyRate: z.number().int().min(0),
|
dailyRate: z.number().int().min(0).optional(),
|
||||||
mileage: z.number().int().optional(),
|
mileage: z.number().int().optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
isPublished: z.boolean().default(true),
|
isPublished: z.boolean().default(true),
|
||||||
@@ -57,9 +57,45 @@ export const maintenanceLogSchema = z.object({
|
|||||||
nextDueMileage: z.number().int().optional(),
|
nextDueMileage: z.number().int().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const nullableRateSchema = z.number().int().min(0).nullable().optional()
|
||||||
|
|
||||||
|
export const pricingConfigSchema = z.object({
|
||||||
|
pricingMode: z.enum(['MANUAL', 'AUTOMATIC']),
|
||||||
|
baseDailyRate: z.number().int().min(0),
|
||||||
|
weeklyRate: nullableRateSchema,
|
||||||
|
weekendRate: nullableRateSchema,
|
||||||
|
holidayRate: nullableRateSchema,
|
||||||
|
monthlyRate: nullableRateSchema,
|
||||||
|
longTermDailyRate: nullableRateSchema,
|
||||||
|
minimumDailyRate: nullableRateSchema,
|
||||||
|
maximumDailyRate: nullableRateSchema,
|
||||||
|
maxDailyPriceMovementPct: z.number().int().min(0).max(100).default(10),
|
||||||
|
automaticPricingEnabled: z.boolean().default(false),
|
||||||
|
approvalRequired: z.boolean().default(false),
|
||||||
|
note: z.string().trim().min(1).max(500).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pricingRuleSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
ruleType: z.enum(['DATE_RANGE', 'SEASONAL', 'HOLIDAY', 'WEEKEND', 'SPECIAL_EVENT', 'MANUAL_OVERRIDE']).default('DATE_RANGE'),
|
||||||
|
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||||
|
endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
|
||||||
|
dailyRate: nullableRateSchema,
|
||||||
|
weeklyRate: nullableRateSchema,
|
||||||
|
weekendRate: nullableRateSchema,
|
||||||
|
monthlyRate: nullableRateSchema,
|
||||||
|
minDailyRate: nullableRateSchema,
|
||||||
|
maxDailyRate: nullableRateSchema,
|
||||||
|
automaticAdjustmentPct: z.number().int().min(-100).max(200).nullable().optional(),
|
||||||
|
priceAdjustment: z.number().int().nullable().optional(),
|
||||||
|
isActive: z.boolean().default(true),
|
||||||
|
sortOrder: z.number().int().min(0).max(1000).default(0),
|
||||||
|
})
|
||||||
|
|
||||||
export const publishSchema = z.object({ isPublished: z.boolean() })
|
export const publishSchema = z.object({ isPublished: z.boolean() })
|
||||||
export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) })
|
export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) })
|
||||||
|
|
||||||
export const idParamSchema = z.object({ id: z.string() })
|
export const idParamSchema = z.object({ id: z.string() })
|
||||||
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
|
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
|
||||||
export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() })
|
export const blockIdParamSchema = z.object({ id: z.string(), blockId: z.string() })
|
||||||
|
export const pricingRuleParamSchema = z.object({ id: z.string(), ruleId: z.string() })
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import { uploadImage } from '../../lib/storage'
|
import { uploadImage } from '../../lib/storage'
|
||||||
import { NotFoundError, ValidationError } from '../../http/errors'
|
import { AppError, NotFoundError, ValidationError } from '../../http/errors'
|
||||||
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
|
||||||
import * as repo from './vehicle.repo'
|
import * as repo from './vehicle.repo'
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const RULE_PRIORITY: Record<string, number> = {
|
||||||
|
MANUAL_OVERRIDE: 6,
|
||||||
|
HOLIDAY: 5,
|
||||||
|
SPECIAL_EVENT: 4,
|
||||||
|
SEASONAL: 3,
|
||||||
|
WEEKEND: 2,
|
||||||
|
DATE_RANGE: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPricingStorageMissing(error: unknown) {
|
||||||
|
if (!error || typeof error !== 'object') return false
|
||||||
|
const candidate = error as { code?: string; message?: string }
|
||||||
|
return (
|
||||||
|
candidate.code === 'P2021' ||
|
||||||
|
candidate.message?.includes('vehicle_pricing_configurations') === true ||
|
||||||
|
candidate.message?.includes('vehicle_pricing_rules') === true ||
|
||||||
|
candidate.message?.includes('vehicle_price_history') === true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLocations(locations: unknown) {
|
function normalizeLocations(locations: unknown) {
|
||||||
if (!Array.isArray(locations)) return []
|
if (!Array.isArray(locations)) return []
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -39,6 +60,291 @@ function applyLocationSettings(data: any) {
|
|||||||
return patch
|
return patch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toStartOfDay(value: string | Date) {
|
||||||
|
const date = new Date(value)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEndOfDay(value: string | Date) {
|
||||||
|
const date = new Date(value)
|
||||||
|
date.setHours(23, 59, 59, 999)
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekend(date: Date) {
|
||||||
|
const day = date.getDay()
|
||||||
|
return day === 0 || day === 6
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampRate(value: number, min?: number | null, max?: number | null) {
|
||||||
|
if (min != null && value < min) return min
|
||||||
|
if (max != null && value > max) return max
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRateBounds(minimum?: number | null, maximum?: number | null, label: string = 'Daily') {
|
||||||
|
if (minimum != null && maximum != null && minimum > maximum) {
|
||||||
|
throw new ValidationError(`${label} minimum rate must be less than or equal to the maximum rate`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePricingConfigurationInput(data: any) {
|
||||||
|
const normalized = {
|
||||||
|
pricingMode: data.pricingMode,
|
||||||
|
baseDailyRate: data.baseDailyRate,
|
||||||
|
weeklyRate: data.weeklyRate ?? null,
|
||||||
|
weekendRate: data.weekendRate ?? null,
|
||||||
|
holidayRate: data.holidayRate ?? null,
|
||||||
|
monthlyRate: data.monthlyRate ?? null,
|
||||||
|
longTermDailyRate: data.longTermDailyRate ?? null,
|
||||||
|
minimumDailyRate: data.minimumDailyRate ?? null,
|
||||||
|
maximumDailyRate: data.maximumDailyRate ?? null,
|
||||||
|
maxDailyPriceMovementPct: data.maxDailyPriceMovementPct,
|
||||||
|
automaticPricingEnabled: data.automaticPricingEnabled,
|
||||||
|
approvalRequired: data.approvalRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRateBounds(normalized.minimumDailyRate, normalized.maximumDailyRate)
|
||||||
|
validateRateBounds(normalized.baseDailyRate, normalized.maximumDailyRate, 'Base')
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePricingRuleInput(data: any) {
|
||||||
|
const normalized = {
|
||||||
|
name: data.name,
|
||||||
|
ruleType: data.ruleType,
|
||||||
|
startDate: toStartOfDay(data.startDate),
|
||||||
|
endDate: toEndOfDay(data.endDate),
|
||||||
|
dailyRate: data.dailyRate ?? null,
|
||||||
|
weeklyRate: data.weeklyRate ?? null,
|
||||||
|
weekendRate: data.weekendRate ?? null,
|
||||||
|
monthlyRate: data.monthlyRate ?? null,
|
||||||
|
minDailyRate: data.minDailyRate ?? null,
|
||||||
|
maxDailyRate: data.maxDailyRate ?? null,
|
||||||
|
automaticAdjustmentPct: data.automaticAdjustmentPct ?? null,
|
||||||
|
priceAdjustment: data.priceAdjustment ?? null,
|
||||||
|
isActive: data.isActive,
|
||||||
|
sortOrder: data.sortOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.endDate < normalized.startDate) {
|
||||||
|
throw new ValidationError('Rule end date must be on or after the start date')
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRateBounds(normalized.minDailyRate, normalized.maxDailyRate)
|
||||||
|
|
||||||
|
const hasPricingEffect = [
|
||||||
|
normalized.dailyRate,
|
||||||
|
normalized.weeklyRate,
|
||||||
|
normalized.weekendRate,
|
||||||
|
normalized.monthlyRate,
|
||||||
|
normalized.minDailyRate,
|
||||||
|
normalized.maxDailyRate,
|
||||||
|
normalized.automaticAdjustmentPct,
|
||||||
|
normalized.priceAdjustment,
|
||||||
|
].some((value) => value != null)
|
||||||
|
|
||||||
|
if (!hasPricingEffect) {
|
||||||
|
throw new ValidationError('Pricing rules must define at least one rate, bound, or adjustment')
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePricingConfigurationForVehicle(vehicleId: string, companyId: string) {
|
||||||
|
const vehicle = await repo.findById(vehicleId, companyId)
|
||||||
|
if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
|
||||||
|
let configuration: any = null
|
||||||
|
let persistenceAvailable = true
|
||||||
|
try {
|
||||||
|
configuration = await repo.findPricingConfiguration(vehicleId)
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPricingStorageMissing(error)) throw error
|
||||||
|
persistenceAvailable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!persistenceAvailable) {
|
||||||
|
return {
|
||||||
|
vehicle,
|
||||||
|
persistenceAvailable,
|
||||||
|
configuration: {
|
||||||
|
id: `transient-${vehicle.id}`,
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
pricingMode: 'MANUAL',
|
||||||
|
baseDailyRate: vehicle.dailyRate,
|
||||||
|
weeklyRate: vehicle.dailyRate * 7,
|
||||||
|
weekendRate: null,
|
||||||
|
holidayRate: null,
|
||||||
|
monthlyRate: null,
|
||||||
|
longTermDailyRate: null,
|
||||||
|
minimumDailyRate: null,
|
||||||
|
maximumDailyRate: null,
|
||||||
|
maxDailyPriceMovementPct: 10,
|
||||||
|
automaticPricingEnabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
rules: [],
|
||||||
|
history: [],
|
||||||
|
createdAt: vehicle.createdAt,
|
||||||
|
updatedAt: vehicle.updatedAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configuration) {
|
||||||
|
configuration = await repo.createPricingConfiguration({
|
||||||
|
vehicleId,
|
||||||
|
pricingMode: 'MANUAL',
|
||||||
|
baseDailyRate: vehicle.dailyRate,
|
||||||
|
weeklyRate: vehicle.dailyRate * 7,
|
||||||
|
automaticPricingEnabled: false,
|
||||||
|
approvalRequired: false,
|
||||||
|
maxDailyPriceMovementPct: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { vehicle, configuration, persistenceAvailable }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPricingBaseRateFromVehicle(vehicleId: string, previousDailyRate: number, nextDailyRate: number) {
|
||||||
|
const configuration = await repo.findPricingConfiguration(vehicleId)
|
||||||
|
if (!configuration) return
|
||||||
|
|
||||||
|
const currentBaseRate = configuration.baseDailyRate ?? previousDailyRate
|
||||||
|
if (currentBaseRate === nextDailyRate) return
|
||||||
|
|
||||||
|
await repo.updatePricingConfiguration(configuration.id, { baseDailyRate: nextDailyRate })
|
||||||
|
await repo.createPriceHistory({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
vehicleId,
|
||||||
|
source: 'CONFIG_UPDATE',
|
||||||
|
previousDailyRate: currentBaseRate,
|
||||||
|
nextDailyRate,
|
||||||
|
previousWeeklyRate: configuration.weeklyRate ?? null,
|
||||||
|
nextWeeklyRate: configuration.weeklyRate ?? null,
|
||||||
|
note: 'Base daily rate synced from vehicle details',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPricePreview(vehicle: any, configuration: any) {
|
||||||
|
const baseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate
|
||||||
|
const activeRules = (configuration.rules ?? []).filter((rule: any) => rule.isActive)
|
||||||
|
const items = Array.from({ length: 14 }, (_, index) => {
|
||||||
|
const date = new Date(Date.now() + (index * DAY_MS))
|
||||||
|
date.setHours(12, 0, 0, 0)
|
||||||
|
let dailyRate = baseDailyRate
|
||||||
|
let pricingType = configuration.pricingMode === 'AUTOMATIC' && configuration.automaticPricingEnabled
|
||||||
|
? 'AUTOMATIC_BASE'
|
||||||
|
: 'MANUAL_BASE'
|
||||||
|
|
||||||
|
if (isWeekend(date) && configuration.weekendRate != null) {
|
||||||
|
dailyRate = configuration.weekendRate
|
||||||
|
pricingType = 'WEEKEND_RATE'
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRule = activeRules
|
||||||
|
.filter((rule: any) => date >= new Date(rule.startDate) && date <= new Date(rule.endDate))
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
const priorityDiff = (RULE_PRIORITY[b.ruleType] ?? 0) - (RULE_PRIORITY[a.ruleType] ?? 0)
|
||||||
|
if (priorityDiff !== 0) return priorityDiff
|
||||||
|
if (a.sortOrder !== b.sortOrder) return b.sortOrder - a.sortOrder
|
||||||
|
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime()
|
||||||
|
})[0]
|
||||||
|
|
||||||
|
if (matchingRule) {
|
||||||
|
if (matchingRule.dailyRate != null) {
|
||||||
|
dailyRate = matchingRule.dailyRate
|
||||||
|
} else {
|
||||||
|
if (matchingRule.weekendRate != null && isWeekend(date)) {
|
||||||
|
dailyRate = matchingRule.weekendRate
|
||||||
|
}
|
||||||
|
if (matchingRule.priceAdjustment != null) {
|
||||||
|
dailyRate += matchingRule.priceAdjustment
|
||||||
|
}
|
||||||
|
if (matchingRule.automaticAdjustmentPct != null) {
|
||||||
|
dailyRate = Math.round(dailyRate * (100 + matchingRule.automaticAdjustmentPct) / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pricingType = configuration.pricingMode === 'AUTOMATIC' ? 'AUTOMATIC_RULE' : 'RULE_RATE'
|
||||||
|
dailyRate = clampRate(
|
||||||
|
dailyRate,
|
||||||
|
matchingRule.minDailyRate ?? configuration.minimumDailyRate,
|
||||||
|
matchingRule.maxDailyRate ?? configuration.maximumDailyRate,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dailyRate = clampRate(dailyRate, configuration.minimumDailyRate, configuration.maximumDailyRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: date.toISOString().slice(0, 10),
|
||||||
|
dailyRate,
|
||||||
|
pricingType,
|
||||||
|
matchedRuleName: matchingRule?.name ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
today: items[0],
|
||||||
|
next14Days: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentVehiclePricing(vehicle: any, configuration: any) {
|
||||||
|
return {
|
||||||
|
configuration: {
|
||||||
|
id: configuration.id,
|
||||||
|
vehicleId: configuration.vehicleId,
|
||||||
|
pricingMode: configuration.pricingMode,
|
||||||
|
baseDailyRate: configuration.baseDailyRate ?? vehicle.dailyRate,
|
||||||
|
weeklyRate: configuration.weeklyRate ?? null,
|
||||||
|
weekendRate: configuration.weekendRate ?? null,
|
||||||
|
holidayRate: configuration.holidayRate ?? null,
|
||||||
|
monthlyRate: configuration.monthlyRate ?? null,
|
||||||
|
longTermDailyRate: configuration.longTermDailyRate ?? null,
|
||||||
|
minimumDailyRate: configuration.minimumDailyRate ?? null,
|
||||||
|
maximumDailyRate: configuration.maximumDailyRate ?? null,
|
||||||
|
maxDailyPriceMovementPct: configuration.maxDailyPriceMovementPct,
|
||||||
|
automaticPricingEnabled: configuration.automaticPricingEnabled,
|
||||||
|
approvalRequired: configuration.approvalRequired,
|
||||||
|
createdAt: configuration.createdAt,
|
||||||
|
updatedAt: configuration.updatedAt,
|
||||||
|
},
|
||||||
|
rules: (configuration.rules ?? []).map((rule: any) => ({
|
||||||
|
id: rule.id,
|
||||||
|
name: rule.name,
|
||||||
|
ruleType: rule.ruleType,
|
||||||
|
startDate: rule.startDate,
|
||||||
|
endDate: rule.endDate,
|
||||||
|
dailyRate: rule.dailyRate,
|
||||||
|
weeklyRate: rule.weeklyRate,
|
||||||
|
weekendRate: rule.weekendRate,
|
||||||
|
monthlyRate: rule.monthlyRate,
|
||||||
|
minDailyRate: rule.minDailyRate,
|
||||||
|
maxDailyRate: rule.maxDailyRate,
|
||||||
|
automaticAdjustmentPct: rule.automaticAdjustmentPct,
|
||||||
|
priceAdjustment: rule.priceAdjustment,
|
||||||
|
isActive: rule.isActive,
|
||||||
|
sortOrder: rule.sortOrder,
|
||||||
|
createdAt: rule.createdAt,
|
||||||
|
updatedAt: rule.updatedAt,
|
||||||
|
})),
|
||||||
|
history: (configuration.history ?? []).map((entry: any) => ({
|
||||||
|
id: entry.id,
|
||||||
|
source: entry.source,
|
||||||
|
changedByEmployeeId: entry.changedByEmployeeId,
|
||||||
|
previousDailyRate: entry.previousDailyRate,
|
||||||
|
nextDailyRate: entry.nextDailyRate,
|
||||||
|
previousWeeklyRate: entry.previousWeeklyRate,
|
||||||
|
nextWeeklyRate: entry.nextWeeklyRate,
|
||||||
|
note: entry.note,
|
||||||
|
effectiveFrom: entry.effectiveFrom,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
})),
|
||||||
|
preview: buildPricePreview(vehicle, configuration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
|
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
|
||||||
const page = query.page ?? 1
|
const page = query.page ?? 1
|
||||||
const pageSize = query.pageSize ?? 20
|
const pageSize = query.pageSize ?? 20
|
||||||
@@ -58,7 +364,11 @@ export async function getVehicle(id: string, companyId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createVehicle(data: any, companyId: string) {
|
export async function createVehicle(data: any, companyId: string) {
|
||||||
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
|
return presentVehicle(await repo.create({
|
||||||
|
dailyRate: 0,
|
||||||
|
...applyLocationSettings(data),
|
||||||
|
companyId,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
|
const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
|
||||||
@@ -70,7 +380,11 @@ export async function updateVehicle(id: string, companyId: string, data: any) {
|
|||||||
}
|
}
|
||||||
const existing = await repo.findFirst(id, companyId)
|
const existing = await repo.findFirst(id, companyId)
|
||||||
if (!existing) throw new NotFoundError('Vehicle not found')
|
if (!existing) throw new NotFoundError('Vehicle not found')
|
||||||
return presentVehicle(await repo.updateById(id, patch))
|
const updated = await repo.updateById(id, patch)
|
||||||
|
if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') {
|
||||||
|
await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate)
|
||||||
|
}
|
||||||
|
return presentVehicle(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setStatus(id: string, companyId: string, status: string) {
|
export async function setStatus(id: string, companyId: string, status: string) {
|
||||||
@@ -169,3 +483,156 @@ export async function createMaintenanceLog(id: string, companyId: string, data:
|
|||||||
nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined,
|
nextDueAt: data.nextDueAt ? new Date(data.nextDueAt) : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVehiclePricing(id: string, companyId: string) {
|
||||||
|
const { vehicle, configuration } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||||
|
return presentVehiclePricing(vehicle, configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVehiclePricing(id: string, companyId: string, employeeId: string, data: any) {
|
||||||
|
const { vehicle, configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||||
|
if (!persistenceAvailable) {
|
||||||
|
throw new AppError(
|
||||||
|
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||||
|
503,
|
||||||
|
'pricing_migration_required',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const nextConfig = normalizePricingConfigurationInput(data)
|
||||||
|
const currentBaseDailyRate = configuration.baseDailyRate ?? vehicle.dailyRate
|
||||||
|
const currentWeeklyRate = configuration.weeklyRate ?? null
|
||||||
|
|
||||||
|
const changed = [
|
||||||
|
'pricingMode',
|
||||||
|
'baseDailyRate',
|
||||||
|
'weeklyRate',
|
||||||
|
'weekendRate',
|
||||||
|
'holidayRate',
|
||||||
|
'monthlyRate',
|
||||||
|
'longTermDailyRate',
|
||||||
|
'minimumDailyRate',
|
||||||
|
'maximumDailyRate',
|
||||||
|
'maxDailyPriceMovementPct',
|
||||||
|
'automaticPricingEnabled',
|
||||||
|
'approvalRequired',
|
||||||
|
].some((key) => ((configuration as any)[key] ?? null) !== ((nextConfig as any)[key] ?? null))
|
||||||
|
|
||||||
|
await repo.updatePricingConfiguration(configuration.id, nextConfig)
|
||||||
|
|
||||||
|
if (vehicle.dailyRate !== nextConfig.baseDailyRate) {
|
||||||
|
await repo.updateById(id, { dailyRate: nextConfig.baseDailyRate })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await repo.createPriceHistory({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
vehicleId: id,
|
||||||
|
source: 'CONFIG_UPDATE',
|
||||||
|
changedByEmployeeId: employeeId,
|
||||||
|
previousDailyRate: currentBaseDailyRate,
|
||||||
|
nextDailyRate: nextConfig.baseDailyRate,
|
||||||
|
previousWeeklyRate: currentWeeklyRate,
|
||||||
|
nextWeeklyRate: nextConfig.weeklyRate ?? null,
|
||||||
|
note: data.note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return getVehiclePricing(id, companyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVehiclePricingRule(id: string, companyId: string, employeeId: string, data: any) {
|
||||||
|
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||||
|
if (!persistenceAvailable) {
|
||||||
|
throw new AppError(
|
||||||
|
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||||
|
503,
|
||||||
|
'pricing_migration_required',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const payload = normalizePricingRuleInput(data)
|
||||||
|
|
||||||
|
const rule = await repo.createPricingRule({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
...payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
await repo.createPriceHistory({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
vehicleId: id,
|
||||||
|
source: payload.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_CREATED',
|
||||||
|
changedByEmployeeId: employeeId,
|
||||||
|
previousDailyRate: configuration.baseDailyRate ?? null,
|
||||||
|
nextDailyRate: payload.dailyRate,
|
||||||
|
previousWeeklyRate: configuration.weeklyRate ?? null,
|
||||||
|
nextWeeklyRate: payload.weeklyRate,
|
||||||
|
note: `Created pricing rule: ${rule.name}`,
|
||||||
|
effectiveFrom: rule.startDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getVehiclePricing(id, companyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string, data: any) {
|
||||||
|
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||||
|
if (!persistenceAvailable) {
|
||||||
|
throw new AppError(
|
||||||
|
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||||
|
503,
|
||||||
|
'pricing_migration_required',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const existingRule = await repo.findPricingRule(ruleId, configuration.id)
|
||||||
|
if (!existingRule) throw new NotFoundError('Pricing rule not found')
|
||||||
|
|
||||||
|
const mergedRule = normalizePricingRuleInput({
|
||||||
|
...existingRule,
|
||||||
|
...data,
|
||||||
|
startDate: data.startDate ?? existingRule.startDate,
|
||||||
|
endDate: data.endDate ?? existingRule.endDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedRule = await repo.updatePricingRule(ruleId, mergedRule)
|
||||||
|
await repo.createPriceHistory({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
vehicleId: id,
|
||||||
|
source: mergedRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_UPDATED',
|
||||||
|
changedByEmployeeId: employeeId,
|
||||||
|
previousDailyRate: existingRule.dailyRate,
|
||||||
|
nextDailyRate: updatedRule.dailyRate,
|
||||||
|
previousWeeklyRate: existingRule.weeklyRate,
|
||||||
|
nextWeeklyRate: updatedRule.weeklyRate,
|
||||||
|
note: `Updated pricing rule: ${updatedRule.name}`,
|
||||||
|
effectiveFrom: updatedRule.startDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getVehiclePricing(id, companyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVehiclePricingRule(id: string, companyId: string, ruleId: string, employeeId: string) {
|
||||||
|
const { configuration, persistenceAvailable } = await ensurePricingConfigurationForVehicle(id, companyId)
|
||||||
|
if (!persistenceAvailable) {
|
||||||
|
throw new AppError(
|
||||||
|
'Vehicle pricing tables are not available yet. Run the latest database migration, then retry.',
|
||||||
|
503,
|
||||||
|
'pricing_migration_required',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const existingRule = await repo.findPricingRule(ruleId, configuration.id)
|
||||||
|
if (!existingRule) throw new NotFoundError('Pricing rule not found')
|
||||||
|
|
||||||
|
await repo.deletePricingRule(ruleId, configuration.id)
|
||||||
|
await repo.createPriceHistory({
|
||||||
|
configurationId: configuration.id,
|
||||||
|
vehicleId: id,
|
||||||
|
source: existingRule.ruleType === 'MANUAL_OVERRIDE' ? 'MANUAL_OVERRIDE' : 'RULE_DELETED',
|
||||||
|
changedByEmployeeId: employeeId,
|
||||||
|
previousDailyRate: existingRule.dailyRate,
|
||||||
|
nextDailyRate: configuration.baseDailyRate ?? null,
|
||||||
|
previousWeeklyRate: existingRule.weeklyRate,
|
||||||
|
nextWeeklyRate: configuration.weeklyRate ?? null,
|
||||||
|
note: `Deleted pricing rule: ${existingRule.name}`,
|
||||||
|
effectiveFrom: existingRule.startDate,
|
||||||
|
})
|
||||||
|
|
||||||
|
return getVehiclePricing(id, companyId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ describe('vehicle.service', () => {
|
|||||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
|
||||||
expect(result).toEqual(mockVehicle)
|
expect(result).toEqual(mockVehicle)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('defaults dailyRate to zero when omitted', async () => {
|
||||||
|
vi.mocked(repo.create).mockResolvedValue({ ...mockVehicle, dailyRate: 0 } as any)
|
||||||
|
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
|
||||||
|
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 }))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('updateVehicle', () => {
|
describe('updateVehicle', () => {
|
||||||
|
|||||||
@@ -77,6 +77,22 @@ describe('Vehicles API', () => {
|
|||||||
expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown'])
|
expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('creates a vehicle without a daily rate and defaults it to zero', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/vehicles')
|
||||||
|
.set(authHeader(token))
|
||||||
|
.send({
|
||||||
|
make: 'Nissan',
|
||||||
|
model: 'Micra',
|
||||||
|
year: 2024,
|
||||||
|
licensePlate: `ZERO-${Date.now()}`,
|
||||||
|
category: 'ECONOMY',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(201)
|
||||||
|
expect(res.body.data.dailyRate).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('returns 400 for missing required fields', async () => {
|
it('returns 400 for missing required fields', async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/v1/vehicles')
|
.post('/api/v1/vehicles')
|
||||||
@@ -228,4 +244,101 @@ describe('Vehicles API', () => {
|
|||||||
expect(res.status).toBe(404)
|
expect(res.status).toBe(404)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Vehicle pricing management', () => {
|
||||||
|
it('returns a default pricing configuration for a vehicle', async () => {
|
||||||
|
const vehicle = await createVehicle(companyId, { dailyRate: 6250 })
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/v1/vehicles/${vehicle.id}/pricing`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.configuration.baseDailyRate).toBe(6250)
|
||||||
|
expect(res.body.data.configuration.pricingMode).toBe('MANUAL')
|
||||||
|
expect(Array.isArray(res.body.data.preview.next14Days)).toBe(true)
|
||||||
|
expect(res.body.data.preview.next14Days.length).toBe(14)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates vehicle pricing configuration and syncs the vehicle daily rate', async () => {
|
||||||
|
const vehicle = await createVehicle(companyId, { dailyRate: 5000 })
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.put(`/api/v1/vehicles/${vehicle.id}/pricing`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
.send({
|
||||||
|
pricingMode: 'AUTOMATIC',
|
||||||
|
baseDailyRate: 6400,
|
||||||
|
weeklyRate: 39900,
|
||||||
|
weekendRate: 7200,
|
||||||
|
holidayRate: null,
|
||||||
|
monthlyRate: 118000,
|
||||||
|
longTermDailyRate: 5600,
|
||||||
|
minimumDailyRate: 5900,
|
||||||
|
maximumDailyRate: 8600,
|
||||||
|
maxDailyPriceMovementPct: 12,
|
||||||
|
automaticPricingEnabled: true,
|
||||||
|
approvalRequired: true,
|
||||||
|
note: 'Summer setup',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.configuration.pricingMode).toBe('AUTOMATIC')
|
||||||
|
expect(res.body.data.configuration.baseDailyRate).toBe(6400)
|
||||||
|
expect(res.body.data.history[0].note).toBe('Summer setup')
|
||||||
|
|
||||||
|
const updatedVehicle = await prisma.vehicle.findUniqueOrThrow({ where: { id: vehicle.id } })
|
||||||
|
expect(updatedVehicle.dailyRate).toBe(6400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates, updates, and deletes pricing rules', async () => {
|
||||||
|
const vehicle = await createVehicle(companyId, { dailyRate: 5400 })
|
||||||
|
|
||||||
|
const createRes = await request(app)
|
||||||
|
.post(`/api/v1/vehicles/${vehicle.id}/pricing/rules`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
.send({
|
||||||
|
name: 'Summer high season',
|
||||||
|
ruleType: 'SEASONAL',
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-08-31',
|
||||||
|
dailyRate: 7800,
|
||||||
|
weeklyRate: 46900,
|
||||||
|
weekendRate: null,
|
||||||
|
monthlyRate: null,
|
||||||
|
minDailyRate: null,
|
||||||
|
maxDailyRate: null,
|
||||||
|
automaticAdjustmentPct: null,
|
||||||
|
priceAdjustment: null,
|
||||||
|
isActive: true,
|
||||||
|
sortOrder: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(createRes.status).toBe(201)
|
||||||
|
expect(createRes.body.data.rules.length).toBe(1)
|
||||||
|
const createdRule = createRes.body.data.rules[0]
|
||||||
|
|
||||||
|
const updateRes = await request(app)
|
||||||
|
.patch(`/api/v1/vehicles/${vehicle.id}/pricing/rules/${createdRule.id}`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
.send({
|
||||||
|
dailyRate: 8100,
|
||||||
|
weeklyRate: 48900,
|
||||||
|
sortOrder: 40,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updateRes.status).toBe(200)
|
||||||
|
const updatedRule = updateRes.body.data.rules.find((rule: any) => rule.id === createdRule.id)
|
||||||
|
expect(updatedRule.dailyRate).toBe(8100)
|
||||||
|
expect(updatedRule.weeklyRate).toBe(48900)
|
||||||
|
|
||||||
|
const deleteRes = await request(app)
|
||||||
|
.delete(`/api/v1/vehicles/${vehicle.id}/pricing/rules/${createdRule.id}`)
|
||||||
|
.set(authHeader(token))
|
||||||
|
|
||||||
|
expect(deleteRes.status).toBe(200)
|
||||||
|
expect(deleteRes.body.data.rules).toHaveLength(0)
|
||||||
|
expect(deleteRes.body.data.history.length).toBeGreaterThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const delegates = [
|
|||||||
'offerVehicle',
|
'offerVehicle',
|
||||||
'offer',
|
'offer',
|
||||||
'vehicleCalendarBlock',
|
'vehicleCalendarBlock',
|
||||||
|
'vehiclePriceHistory',
|
||||||
|
'vehiclePricingRule',
|
||||||
|
'vehiclePricingConfiguration',
|
||||||
'maintenanceLog',
|
'maintenanceLog',
|
||||||
'vehicle',
|
'vehicle',
|
||||||
'employee',
|
'employee',
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/app.ts",
|
||||||
|
"src/http/**/*.ts",
|
||||||
|
"src/lib/**/*.ts",
|
||||||
|
"src/middleware/**/*.ts",
|
||||||
|
"src/modules/vehicles/**/*.ts",
|
||||||
|
"src/services/vehicleAvailabilityService.ts",
|
||||||
|
"src/tests/helpers/**/*.ts",
|
||||||
|
"src/tests/integration/vehicles.test.ts",
|
||||||
|
"src/tests/setup.ts",
|
||||||
|
"src/types/**/*.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": []
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
|
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, DollarSign, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
import { formatCurrency } from '@rentaldrivego/types'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import VehicleCalendar from '@/components/VehicleCalendar'
|
import VehicleCalendar from '@/components/VehicleCalendar'
|
||||||
|
import VehiclePricingManager from '@/components/VehiclePricingManager'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
|
|
||||||
interface VehicleDetail {
|
interface VehicleDetail {
|
||||||
@@ -23,6 +24,7 @@ interface VehicleDetail {
|
|||||||
seats: number
|
seats: number
|
||||||
mileage: number | null
|
mileage: number | null
|
||||||
licensePlate: string
|
licensePlate: string
|
||||||
|
vin: string | null
|
||||||
photos: string[]
|
photos: string[]
|
||||||
notes: string | null
|
notes: string | null
|
||||||
isPublished: boolean
|
isPublished: boolean
|
||||||
@@ -276,12 +278,13 @@ function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, lo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'details' | 'maintenance' | 'calendar'
|
type Tab = 'details' | 'maintenance' | 'calendar' | 'pricing'
|
||||||
|
|
||||||
export default function FleetDetailPage() {
|
export default function FleetDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { dict } = useDashboardI18n()
|
const searchParams = useSearchParams()
|
||||||
|
const { dict, language } = useDashboardI18n()
|
||||||
const vd = dict.vehicleDetail
|
const vd = dict.vehicleDetail
|
||||||
const fl = dict.fleet
|
const fl = dict.fleet
|
||||||
|
|
||||||
@@ -294,7 +297,7 @@ export default function FleetDetailPage() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<{
|
const [form, setForm] = useState<{
|
||||||
make: string; model: string; year: number; category: string
|
make: string; model: string; year: number; category: string
|
||||||
dailyRate: string; licensePlate: string; color: string
|
dailyRate: string; licensePlate: string; vin: string; color: string
|
||||||
seats: number; transmission: string; fuelType: string
|
seats: number; transmission: string; fuelType: string
|
||||||
status: string; notes: string; mileage: string
|
status: string; notes: string; mileage: string
|
||||||
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
|
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
|
||||||
@@ -325,6 +328,12 @@ export default function FleetDetailPage() {
|
|||||||
.catch((err) => setError(err.message))
|
.catch((err) => setError(err.message))
|
||||||
}, [params.id])
|
}, [params.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('tab') === 'pricing') {
|
||||||
|
setActiveTab('pricing')
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
if (!vehicle) return
|
if (!vehicle) return
|
||||||
setForm({
|
setForm({
|
||||||
@@ -332,6 +341,7 @@ export default function FleetDetailPage() {
|
|||||||
category: vehicle.category,
|
category: vehicle.category,
|
||||||
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
|
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
|
||||||
licensePlate: vehicle.licensePlate,
|
licensePlate: vehicle.licensePlate,
|
||||||
|
vin: vehicle.vin ?? '',
|
||||||
color: vehicle.color ?? '', seats: vehicle.seats,
|
color: vehicle.color ?? '', seats: vehicle.seats,
|
||||||
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
|
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
|
||||||
status: vehicle.status, notes: vehicle.notes ?? '',
|
status: vehicle.status, notes: vehicle.notes ?? '',
|
||||||
@@ -376,7 +386,7 @@ export default function FleetDetailPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
make: form.make, model: form.model, year: Number(form.year),
|
make: form.make, model: form.model, year: Number(form.year),
|
||||||
category: form.category, dailyRate: Math.round(rate * 100),
|
category: form.category, dailyRate: Math.round(rate * 100),
|
||||||
licensePlate: form.licensePlate, color: form.color,
|
licensePlate: form.licensePlate, vin: form.vin.trim() || undefined, color: form.color,
|
||||||
seats: Number(form.seats), transmission: form.transmission,
|
seats: Number(form.seats), transmission: form.transmission,
|
||||||
fuelType: form.fuelType, status: form.status,
|
fuelType: form.fuelType, status: form.status,
|
||||||
notes: form.notes || undefined,
|
notes: form.notes || undefined,
|
||||||
@@ -449,6 +459,7 @@ export default function FleetDetailPage() {
|
|||||||
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
|
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
|
||||||
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
|
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
|
||||||
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
|
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
|
||||||
|
const pricingTabLabel = language === 'fr' ? 'Tarifs' : language === 'ar' ? 'التسعير' : 'Pricing'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -460,7 +471,12 @@ export default function FleetDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
||||||
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {statusLabel}</p>
|
<p className="text-sm text-slate-500 mt-0.5">
|
||||||
|
{vehicle.year} · {vehicle.licensePlate}
|
||||||
|
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
|
||||||
|
{' · '}
|
||||||
|
{statusLabel}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!editing ? (
|
{!editing ? (
|
||||||
@@ -486,7 +502,7 @@ export default function FleetDetailPage() {
|
|||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
|
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
|
||||||
{(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => (
|
{(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
@@ -505,6 +521,7 @@ export default function FleetDetailPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
|
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
|
||||||
|
{tab === 'pricing' && <><DollarSign className="w-4 h-4" /> {pricingTabLabel}</>}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -578,6 +595,7 @@ export default function FleetDetailPage() {
|
|||||||
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
|
||||||
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||||
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
|
||||||
|
<div><dt className="text-slate-500">{vd.vinLabel}</dt><dd className="text-slate-900">{vehicle.vin || '—'}</dd></div>
|
||||||
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||||
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
|
||||||
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
|
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
|
||||||
@@ -657,8 +675,8 @@ export default function FleetDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Rate & License Plate */}
|
{/* Daily Rate, License Plate & VIN */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
|
||||||
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||||
@@ -668,6 +686,10 @@ export default function FleetDetailPage() {
|
|||||||
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.vinLabel}</label>
|
||||||
|
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
<div>
|
<div>
|
||||||
@@ -807,6 +829,10 @@ export default function FleetDetailPage() {
|
|||||||
{activeTab === 'calendar' && (
|
{activeTab === 'calendar' && (
|
||||||
<VehicleCalendar vehicleId={params.id} />
|
<VehicleCalendar vehicleId={params.id} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'pricing' && (
|
||||||
|
<VehiclePricingManager vehicleId={params.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown } from 'lucide-react'
|
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
@@ -19,6 +19,7 @@ interface Vehicle {
|
|||||||
isPublished: boolean
|
isPublished: boolean
|
||||||
primaryPhoto: string | null
|
primaryPhoto: string | null
|
||||||
licensePlate: string
|
licensePlate: string
|
||||||
|
vin?: string | null
|
||||||
pickupLocations?: string[]
|
pickupLocations?: string[]
|
||||||
allowDifferentDropoff?: boolean
|
allowDifferentDropoff?: boolean
|
||||||
dropoffLocations?: string[]
|
dropoffLocations?: string[]
|
||||||
@@ -279,7 +280,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
const f = dict.fleet
|
const f = dict.fleet
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
||||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
|
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
|
||||||
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
|
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
|
||||||
})
|
})
|
||||||
const [colorSelect, setColorSelect] = useState('')
|
const [colorSelect, setColorSelect] = useState('')
|
||||||
@@ -311,7 +312,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
const reset = () => {
|
const reset = () => {
|
||||||
setForm({
|
setForm({
|
||||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
||||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
|
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
|
||||||
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
|
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
|
||||||
})
|
})
|
||||||
setColorSelect('')
|
setColorSelect('')
|
||||||
@@ -330,7 +331,6 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
if (!form.make) { setError(f.makeMissing); return }
|
if (!form.make) { setError(f.makeMissing); return }
|
||||||
if (!form.model) { setError(f.modelMissing); return }
|
if (!form.model) { setError(f.modelMissing); return }
|
||||||
if (!form.licensePlate) { setError(f.plateMissing); return }
|
if (!form.licensePlate) { setError(f.plateMissing); return }
|
||||||
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return }
|
|
||||||
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
|
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
|
||||||
setError(f.dropoffLocationsRequired)
|
setError(f.dropoffLocationsRequired)
|
||||||
return
|
return
|
||||||
@@ -342,9 +342,9 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...form,
|
...form,
|
||||||
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
|
|
||||||
year: Number(form.year),
|
year: Number(form.year),
|
||||||
seats: Number(form.seats),
|
seats: Number(form.seats),
|
||||||
|
vin: form.vin.trim() || undefined,
|
||||||
mileage: form.mileage ? Number(form.mileage) : undefined,
|
mileage: form.mileage ? Number(form.mileage) : undefined,
|
||||||
pickupLocations: parseLocationInput(form.pickupLocations),
|
pickupLocations: parseLocationInput(form.pickupLocations),
|
||||||
allowDifferentDropoff: form.allowDifferentDropoff,
|
allowDifferentDropoff: form.allowDifferentDropoff,
|
||||||
@@ -442,16 +442,16 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Rate & License Plate */}
|
{/* License Plate & VIN */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dailyRate}</label>
|
|
||||||
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
|
||||||
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
|
||||||
|
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color, Seats, Transmission */}
|
{/* Color, Seats, Transmission */}
|
||||||
@@ -649,7 +649,7 @@ export default function FleetPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = vehicles.filter((v) => {
|
const filtered = vehicles.filter((v) => {
|
||||||
if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false
|
if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false
|
||||||
if (statusFilter && v.status !== statusFilter) return false
|
if (statusFilter && v.status !== statusFilter) return false
|
||||||
if (categoryFilter && v.category !== categoryFilter) return false
|
if (categoryFilter && v.category !== categoryFilter) return false
|
||||||
if (publishedFilter === 'published' && !v.isPublished) return false
|
if (publishedFilter === 'published' && !v.isPublished) return false
|
||||||
@@ -729,7 +729,10 @@ export default function FleetPage() {
|
|||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||||
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
<p className="text-xs text-slate-400">
|
||||||
|
{vehicle.year} · {vehicle.licensePlate}
|
||||||
|
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
@@ -761,6 +764,9 @@ export default function FleetPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center justify-end gap-2 rtl:justify-start">
|
<div className="flex items-center justify-end gap-2 rtl:justify-start">
|
||||||
|
<Link href={`/fleet/${vehicle.id}?tab=pricing`} title="Manage pricing" aria-label="Manage pricing" className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||||
|
<DollarSign className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type FleetDict = {
|
|||||||
category: string
|
category: string
|
||||||
dailyRate: string
|
dailyRate: string
|
||||||
licensePlate: string
|
licensePlate: string
|
||||||
|
vin: string
|
||||||
color: string
|
color: string
|
||||||
select: string
|
select: string
|
||||||
typeColor: string
|
typeColor: string
|
||||||
@@ -253,6 +254,7 @@ type VehicleDetailDict = {
|
|||||||
yearLabel: string
|
yearLabel: string
|
||||||
dailyRateLabel: string
|
dailyRateLabel: string
|
||||||
licensePlateLabel: string
|
licensePlateLabel: string
|
||||||
|
vinLabel: string
|
||||||
statusLabel: string
|
statusLabel: string
|
||||||
colorLabel: string
|
colorLabel: string
|
||||||
selectColor: string
|
selectColor: string
|
||||||
@@ -393,6 +395,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
category: 'Category',
|
category: 'Category',
|
||||||
dailyRate: 'Daily Rate (MAD) *',
|
dailyRate: 'Daily Rate (MAD) *',
|
||||||
licensePlate: 'License Plate *',
|
licensePlate: 'License Plate *',
|
||||||
|
vin: 'VIN',
|
||||||
color: 'Color',
|
color: 'Color',
|
||||||
select: 'Select…',
|
select: 'Select…',
|
||||||
typeColor: 'Type color…',
|
typeColor: 'Type color…',
|
||||||
@@ -487,6 +490,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
yearLabel: 'Year *',
|
yearLabel: 'Year *',
|
||||||
dailyRateLabel: 'Daily Rate (MAD) *',
|
dailyRateLabel: 'Daily Rate (MAD) *',
|
||||||
licensePlateLabel: 'License Plate *',
|
licensePlateLabel: 'License Plate *',
|
||||||
|
vinLabel: 'VIN',
|
||||||
statusLabel: 'Status',
|
statusLabel: 'Status',
|
||||||
colorLabel: 'Color',
|
colorLabel: 'Color',
|
||||||
selectColor: 'Select color…',
|
selectColor: 'Select color…',
|
||||||
@@ -747,6 +751,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
category: 'Catégorie',
|
category: 'Catégorie',
|
||||||
dailyRate: 'Tarif journalier (MAD) *',
|
dailyRate: 'Tarif journalier (MAD) *',
|
||||||
licensePlate: "Plaque d'immatriculation *",
|
licensePlate: "Plaque d'immatriculation *",
|
||||||
|
vin: 'VIN',
|
||||||
color: 'Couleur',
|
color: 'Couleur',
|
||||||
select: 'Sélectionner…',
|
select: 'Sélectionner…',
|
||||||
typeColor: 'Saisir la couleur…',
|
typeColor: 'Saisir la couleur…',
|
||||||
@@ -841,6 +846,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
yearLabel: 'Année *',
|
yearLabel: 'Année *',
|
||||||
dailyRateLabel: 'Tarif journalier (MAD) *',
|
dailyRateLabel: 'Tarif journalier (MAD) *',
|
||||||
licensePlateLabel: "Plaque d'immatriculation *",
|
licensePlateLabel: "Plaque d'immatriculation *",
|
||||||
|
vinLabel: 'VIN',
|
||||||
statusLabel: 'Statut',
|
statusLabel: 'Statut',
|
||||||
colorLabel: 'Couleur',
|
colorLabel: 'Couleur',
|
||||||
selectColor: 'Sélectionner la couleur…',
|
selectColor: 'Sélectionner la couleur…',
|
||||||
@@ -1101,6 +1107,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
category: 'الفئة',
|
category: 'الفئة',
|
||||||
dailyRate: 'السعر اليومي (درهم) *',
|
dailyRate: 'السعر اليومي (درهم) *',
|
||||||
licensePlate: 'رقم اللوحة *',
|
licensePlate: 'رقم اللوحة *',
|
||||||
|
vin: 'رقم الهيكل',
|
||||||
color: 'اللون',
|
color: 'اللون',
|
||||||
select: 'اختر…',
|
select: 'اختر…',
|
||||||
typeColor: 'اكتب اللون…',
|
typeColor: 'اكتب اللون…',
|
||||||
@@ -1195,6 +1202,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
|||||||
yearLabel: 'السنة *',
|
yearLabel: 'السنة *',
|
||||||
dailyRateLabel: 'السعر اليومي (درهم) *',
|
dailyRateLabel: 'السعر اليومي (درهم) *',
|
||||||
licensePlateLabel: 'رقم اللوحة *',
|
licensePlateLabel: 'رقم اللوحة *',
|
||||||
|
vinLabel: 'رقم الهيكل',
|
||||||
statusLabel: 'الحالة',
|
statusLabel: 'الحالة',
|
||||||
colorLabel: 'اللون',
|
colorLabel: 'اللون',
|
||||||
selectColor: 'اختر اللون…',
|
selectColor: 'اختر اللون…',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../car_rental_app"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# Rental Car Pricing Management Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Build a rental car pricing system that allows the business to manage vehicle prices using two pricing methods:
|
||||||
|
|
||||||
|
1. **Manual Fixed Pricing**: The user sets daily and/or weekly prices for each car throughout the year.
|
||||||
|
2. **Automatic Dynamic Pricing**: The user sets minimum and maximum daily prices, and the system automatically adjusts prices based on search activity, demand, availability, seasonality, and booking behavior.
|
||||||
|
|
||||||
|
The system should support both methods so the business can choose between full control and automated optimization.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method 1: Manual Fixed Pricing
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Manual fixed pricing allows the user to set exact prices for each vehicle. Prices can be configured by day, week, date range, season, holiday, or special event.
|
||||||
|
|
||||||
|
This method is useful when the business wants predictable pricing and full control.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
#### 1. Car-Level Pricing
|
||||||
|
|
||||||
|
Each vehicle should have its own pricing settings.
|
||||||
|
|
||||||
|
| Car | Daily Price | Weekly Price |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Toyota Corolla | $45 | $280 |
|
||||||
|
| Hyundai Elantra | $50 | $310 |
|
||||||
|
| Ford Mustang | $95 | $600 |
|
||||||
|
| Chevrolet Suburban | $120 | $750 |
|
||||||
|
|
||||||
|
#### 2. Date-Based Pricing
|
||||||
|
|
||||||
|
The user should be able to set different prices for different date ranges.
|
||||||
|
|
||||||
|
| Date Range | Daily Price | Weekly Price |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Jan 1 - Mar 31 | $45 | $280 |
|
||||||
|
| Apr 1 - Aug 31 | $60 | $380 |
|
||||||
|
| Sep 1 - Dec 15 | $50 | $320 |
|
||||||
|
| Dec 16 - Dec 31 | $75 | $475 |
|
||||||
|
|
||||||
|
#### 3. Seasonal Pricing
|
||||||
|
|
||||||
|
The system should support pricing rules for:
|
||||||
|
|
||||||
|
- Low season
|
||||||
|
- High season
|
||||||
|
- Holidays
|
||||||
|
- Weekends
|
||||||
|
- Special events
|
||||||
|
- Summer travel season
|
||||||
|
- End-of-year demand
|
||||||
|
|
||||||
|
#### 4. Daily and Weekly Price Options
|
||||||
|
|
||||||
|
The user should be able to set:
|
||||||
|
|
||||||
|
- Daily rental price
|
||||||
|
- Weekly rental price
|
||||||
|
- Weekend price
|
||||||
|
- Holiday price
|
||||||
|
- Monthly price, if needed
|
||||||
|
- Discounted long-term rental price
|
||||||
|
|
||||||
|
#### 5. Bulk Price Updates
|
||||||
|
|
||||||
|
The system should allow the user to update many prices at once.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Increase all SUV prices by 15% for summer
|
||||||
|
- Set all economy cars to $40/day for February
|
||||||
|
- Apply holiday pricing to all cars from December 20 to January 5
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
Manual pricing is simple, predictable, and gives the user full control.
|
||||||
|
|
||||||
|
### Risks
|
||||||
|
|
||||||
|
Manual pricing can become outdated quickly. If demand rises and prices stay low, the business loses revenue. If demand drops and prices stay high, cars may remain unused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Method 2: Automatic Dynamic Pricing
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Automatic dynamic pricing allows the user to set a minimum and maximum daily price for each car. The system then calculates the best price within that range based on demand and business rules.
|
||||||
|
|
||||||
|
The system should never price below the minimum or above the maximum unless the user manually overrides it.
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
#### 1. Minimum and Maximum Price Rules
|
||||||
|
|
||||||
|
Each car should have a minimum and maximum daily price.
|
||||||
|
|
||||||
|
| Car | Minimum Daily Price | Maximum Daily Price |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Toyota Corolla | $35 | $75 |
|
||||||
|
| Hyundai Elantra | $40 | $85 |
|
||||||
|
| Ford Mustang | $75 | $160 |
|
||||||
|
| Chevrolet Suburban | $90 | $220 |
|
||||||
|
|
||||||
|
#### 2. Demand-Based Pricing
|
||||||
|
|
||||||
|
The system should increase prices when demand is high and lower prices when demand is weak.
|
||||||
|
|
||||||
|
Demand signals may include:
|
||||||
|
|
||||||
|
- Number of searches for a car type
|
||||||
|
- Number of views on a specific vehicle
|
||||||
|
- Number of booking attempts
|
||||||
|
- Number of confirmed reservations
|
||||||
|
- Number of available cars
|
||||||
|
- Time remaining before rental date
|
||||||
|
- Canceled bookings
|
||||||
|
- Competitor pricing, if available
|
||||||
|
- Local events or holidays
|
||||||
|
|
||||||
|
#### 3. Availability-Based Pricing
|
||||||
|
|
||||||
|
The system should adjust prices based on available inventory.
|
||||||
|
|
||||||
|
| Availability | Pricing Action |
|
||||||
|
|---|---|
|
||||||
|
| Many cars available | Lower price to increase bookings |
|
||||||
|
| Medium availability | Keep price near normal |
|
||||||
|
| Few cars available | Increase price |
|
||||||
|
| One car left in category | Increase toward maximum price |
|
||||||
|
|
||||||
|
#### 4. Search-Based Pricing
|
||||||
|
|
||||||
|
If many users search for a specific car, category, or date range, the system should treat that as demand.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
If many users search for SUVs during July 4 weekend, SUV prices should increase automatically within the allowed price range.
|
||||||
|
|
||||||
|
Searches alone should not trigger aggressive increases. The system should compare searches with booking behavior.
|
||||||
|
|
||||||
|
#### 5. Booking Conversion Logic
|
||||||
|
|
||||||
|
The system should compare searches to actual bookings.
|
||||||
|
|
||||||
|
| Search Activity | Booking Activity | Pricing Action |
|
||||||
|
|---|---|---|
|
||||||
|
| High searches | High bookings | Raise price |
|
||||||
|
| High searches | Low bookings | Hold price or reduce slightly |
|
||||||
|
| Low searches | Low bookings | Reduce price |
|
||||||
|
| Low searches | High bookings | Raise price carefully |
|
||||||
|
|
||||||
|
#### 6. Time-Based Pricing
|
||||||
|
|
||||||
|
The system should adjust prices based on how close the rental date is.
|
||||||
|
|
||||||
|
| Time Before Rental | Pricing Logic |
|
||||||
|
|---|---|
|
||||||
|
| 60+ days before | Keep price moderate |
|
||||||
|
| 30-60 days before | Adjust based on demand |
|
||||||
|
| 7-30 days before | Increase if availability is low |
|
||||||
|
| 1-7 days before | Discount if many cars remain, increase if few cars remain |
|
||||||
|
|
||||||
|
#### 7. Category-Based Pricing
|
||||||
|
|
||||||
|
Automatic pricing should work at both the vehicle level and category level.
|
||||||
|
|
||||||
|
Categories may include:
|
||||||
|
|
||||||
|
- Economy
|
||||||
|
- Compact
|
||||||
|
- Sedan
|
||||||
|
- SUV
|
||||||
|
- Luxury
|
||||||
|
- Sports car
|
||||||
|
- Van
|
||||||
|
- Truck
|
||||||
|
|
||||||
|
If SUV demand is high, SUV prices may increase while economy car prices remain unchanged.
|
||||||
|
|
||||||
|
#### 8. Manual Override
|
||||||
|
|
||||||
|
The user should always be able to override automatic pricing.
|
||||||
|
|
||||||
|
Manual override options should include:
|
||||||
|
|
||||||
|
- Lock price for a specific car
|
||||||
|
- Lock price for a date range
|
||||||
|
- Disable automatic pricing for selected vehicles
|
||||||
|
- Approve price changes manually before they go live
|
||||||
|
- Set maximum daily price movement, such as no more than 10% per day
|
||||||
|
|
||||||
|
#### 9. Pricing Safety Rules
|
||||||
|
|
||||||
|
To prevent bad automated pricing, the system should include safety rules:
|
||||||
|
|
||||||
|
- Do not change prices more than once per day unless needed
|
||||||
|
- Do not increase prices above the user’s maximum price
|
||||||
|
- Do not decrease prices below the user’s minimum price
|
||||||
|
- Do not raise prices aggressively based only on searches
|
||||||
|
- Do not discount high-demand dates too early
|
||||||
|
- Notify the user when prices change significantly
|
||||||
|
- Keep a pricing history for review
|
||||||
|
|
||||||
|
#### 10. Pricing Formula Example
|
||||||
|
|
||||||
|
A simple automatic pricing formula:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Automatic Price = Base Price
|
||||||
|
+ Demand Adjustment
|
||||||
|
+ Availability Adjustment
|
||||||
|
+ Seasonality Adjustment
|
||||||
|
+ Time Adjustment
|
||||||
|
```
|
||||||
|
|
||||||
|
Then the system applies price limits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
If calculated price < minimum price:
|
||||||
|
final price = minimum price
|
||||||
|
|
||||||
|
If calculated price > maximum price:
|
||||||
|
final price = maximum price
|
||||||
|
|
||||||
|
Otherwise:
|
||||||
|
final price = calculated price
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
| Item | Amount |
|
||||||
|
|---|---:|
|
||||||
|
| Base price | $60 |
|
||||||
|
| Demand adjustment | +$10 |
|
||||||
|
| Availability adjustment | +$15 |
|
||||||
|
| Holiday adjustment | +$20 |
|
||||||
|
| Time adjustment | +$5 |
|
||||||
|
| Calculated price | $110 |
|
||||||
|
|
||||||
|
If the user’s maximum price is $100, the final price should be **$100**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended System Design
|
||||||
|
|
||||||
|
### Pricing Dashboard
|
||||||
|
|
||||||
|
The pricing dashboard should allow the user to:
|
||||||
|
|
||||||
|
- View all cars
|
||||||
|
- See current prices
|
||||||
|
- Set manual prices
|
||||||
|
- Set minimum and maximum prices
|
||||||
|
- Turn automatic pricing on or off
|
||||||
|
- View pricing history
|
||||||
|
- Review demand trends
|
||||||
|
- Approve or reject suggested price changes
|
||||||
|
|
||||||
|
### Car Pricing Page
|
||||||
|
|
||||||
|
Each car should have a pricing page with:
|
||||||
|
|
||||||
|
- Default daily price
|
||||||
|
- Default weekly price
|
||||||
|
- Minimum daily price
|
||||||
|
- Maximum daily price
|
||||||
|
- Seasonal price rules
|
||||||
|
- Manual pricing calendar
|
||||||
|
- Automatic pricing status
|
||||||
|
- Price change history
|
||||||
|
|
||||||
|
### Calendar Pricing View
|
||||||
|
|
||||||
|
The system should include a calendar view where users can see and edit prices by date.
|
||||||
|
|
||||||
|
| Date | Price | Pricing Type |
|
||||||
|
|---|---:|---|
|
||||||
|
| June 1 | $55 | Manual |
|
||||||
|
| June 2 | $58 | Automatic |
|
||||||
|
| June 3 | $60 | Automatic |
|
||||||
|
| July 4 | $95 | Holiday Rule |
|
||||||
|
|
||||||
|
### Reports and Analytics
|
||||||
|
|
||||||
|
The system should generate reports showing:
|
||||||
|
|
||||||
|
- Revenue per car
|
||||||
|
- Revenue per category
|
||||||
|
- Occupancy rate
|
||||||
|
- Average daily rate
|
||||||
|
- Search demand
|
||||||
|
- Booking conversion rate
|
||||||
|
- Lost bookings
|
||||||
|
- Price change history
|
||||||
|
- Best-performing price ranges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggested Pricing Workflow
|
||||||
|
|
||||||
|
### Step 1: Add Cars
|
||||||
|
|
||||||
|
The user adds each car to the system with details such as:
|
||||||
|
|
||||||
|
- Make
|
||||||
|
- Model
|
||||||
|
- Year
|
||||||
|
- Category
|
||||||
|
- Location
|
||||||
|
- Availability
|
||||||
|
- Default daily price
|
||||||
|
- Default weekly price
|
||||||
|
|
||||||
|
### Step 2: Choose Pricing Method
|
||||||
|
|
||||||
|
For each car, the user chooses:
|
||||||
|
|
||||||
|
- Manual fixed pricing
|
||||||
|
- Automatic dynamic pricing
|
||||||
|
|
||||||
|
### Step 3: Set Pricing Rules
|
||||||
|
|
||||||
|
For manual pricing, the user sets exact daily and weekly prices.
|
||||||
|
|
||||||
|
For automatic pricing, the user sets:
|
||||||
|
|
||||||
|
- Minimum daily price
|
||||||
|
- Maximum daily price
|
||||||
|
- Base price
|
||||||
|
- Weekly discount rules
|
||||||
|
- Seasonal rules
|
||||||
|
- Safety limits
|
||||||
|
|
||||||
|
### Step 4: Monitor Demand
|
||||||
|
|
||||||
|
The system tracks:
|
||||||
|
|
||||||
|
- Searches
|
||||||
|
- Views
|
||||||
|
- Bookings
|
||||||
|
- Cancellations
|
||||||
|
- Availability
|
||||||
|
- Date demand
|
||||||
|
- Category demand
|
||||||
|
|
||||||
|
### Step 5: Adjust Prices
|
||||||
|
|
||||||
|
Manual prices remain fixed unless the user changes them.
|
||||||
|
|
||||||
|
Automatic prices adjust based on system rules.
|
||||||
|
|
||||||
|
### Step 6: Review Performance
|
||||||
|
|
||||||
|
The user reviews pricing performance and updates rules when needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practice Recommendation
|
||||||
|
|
||||||
|
The strongest approach is to support both pricing methods at the same time.
|
||||||
|
|
||||||
|
Some vehicles should use manual pricing, especially rare, luxury, sports, or specialty cars where strict control matters.
|
||||||
|
|
||||||
|
Other vehicles should use automatic pricing, especially common vehicles where demand changes frequently.
|
||||||
|
|
||||||
|
| Vehicle Type | Best Pricing Method |
|
||||||
|
|---|---|
|
||||||
|
| Economy cars | Automatic pricing |
|
||||||
|
| Sedans | Automatic pricing |
|
||||||
|
| SUVs | Automatic pricing |
|
||||||
|
| Luxury cars | Manual or automatic with tight limits |
|
||||||
|
| Sports cars | Manual pricing |
|
||||||
|
| Vans | Automatic pricing with seasonal rules |
|
||||||
|
| Specialty cars | Manual pricing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Recommendation
|
||||||
|
|
||||||
|
The rental car pricing system should include both manual and automatic pricing.
|
||||||
|
|
||||||
|
Manual pricing gives the user full control over daily and weekly rates throughout the year.
|
||||||
|
|
||||||
|
Automatic pricing allows the user to set minimum and maximum limits while the system adjusts prices based on demand, searches, bookings, availability, seasonality, and time before rental.
|
||||||
|
|
||||||
|
The best system is a hybrid model: the user controls the boundaries, and the system optimizes prices inside those boundaries.
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
CREATE TYPE "VehiclePricingMode" AS ENUM ('MANUAL', 'AUTOMATIC');
|
||||||
|
|
||||||
|
CREATE TYPE "VehiclePricingRuleType" AS ENUM (
|
||||||
|
'DATE_RANGE',
|
||||||
|
'SEASONAL',
|
||||||
|
'HOLIDAY',
|
||||||
|
'WEEKEND',
|
||||||
|
'SPECIAL_EVENT',
|
||||||
|
'MANUAL_OVERRIDE'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TYPE "VehiclePriceChangeSource" AS ENUM (
|
||||||
|
'CONFIG_UPDATE',
|
||||||
|
'RULE_CREATED',
|
||||||
|
'RULE_UPDATED',
|
||||||
|
'RULE_DELETED',
|
||||||
|
'MANUAL_OVERRIDE'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "vehicle_pricing_configurations" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"vehicleId" TEXT NOT NULL,
|
||||||
|
"pricingMode" "VehiclePricingMode" NOT NULL DEFAULT 'MANUAL',
|
||||||
|
"baseDailyRate" INTEGER,
|
||||||
|
"weeklyRate" INTEGER,
|
||||||
|
"weekendRate" INTEGER,
|
||||||
|
"holidayRate" INTEGER,
|
||||||
|
"monthlyRate" INTEGER,
|
||||||
|
"longTermDailyRate" INTEGER,
|
||||||
|
"minimumDailyRate" INTEGER,
|
||||||
|
"maximumDailyRate" INTEGER,
|
||||||
|
"maxDailyPriceMovementPct" INTEGER NOT NULL DEFAULT 10,
|
||||||
|
"automaticPricingEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"approvalRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "vehicle_pricing_configurations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "vehicle_pricing_configurations_vehicleId_key"
|
||||||
|
ON "vehicle_pricing_configurations"("vehicleId");
|
||||||
|
|
||||||
|
CREATE TABLE "vehicle_pricing_rules" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"configurationId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"ruleType" "VehiclePricingRuleType" NOT NULL DEFAULT 'DATE_RANGE',
|
||||||
|
"startDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"endDate" TIMESTAMP(3) NOT NULL,
|
||||||
|
"dailyRate" INTEGER,
|
||||||
|
"weeklyRate" INTEGER,
|
||||||
|
"weekendRate" INTEGER,
|
||||||
|
"monthlyRate" INTEGER,
|
||||||
|
"minDailyRate" INTEGER,
|
||||||
|
"maxDailyRate" INTEGER,
|
||||||
|
"automaticAdjustmentPct" INTEGER,
|
||||||
|
"priceAdjustment" INTEGER,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "vehicle_pricing_rules_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "vehicle_pricing_rules_configurationId_idx"
|
||||||
|
ON "vehicle_pricing_rules"("configurationId");
|
||||||
|
|
||||||
|
CREATE INDEX "vehicle_pricing_rules_configurationId_startDate_endDate_idx"
|
||||||
|
ON "vehicle_pricing_rules"("configurationId", "startDate", "endDate");
|
||||||
|
|
||||||
|
CREATE TABLE "vehicle_price_history" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"configurationId" TEXT NOT NULL,
|
||||||
|
"vehicleId" TEXT NOT NULL,
|
||||||
|
"source" "VehiclePriceChangeSource" NOT NULL DEFAULT 'CONFIG_UPDATE',
|
||||||
|
"changedByEmployeeId" TEXT,
|
||||||
|
"previousDailyRate" INTEGER,
|
||||||
|
"nextDailyRate" INTEGER,
|
||||||
|
"previousWeeklyRate" INTEGER,
|
||||||
|
"nextWeeklyRate" INTEGER,
|
||||||
|
"note" TEXT,
|
||||||
|
"effectiveFrom" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "vehicle_price_history_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "vehicle_price_history_vehicleId_createdAt_idx"
|
||||||
|
ON "vehicle_price_history"("vehicleId", "createdAt");
|
||||||
|
|
||||||
|
CREATE INDEX "vehicle_price_history_configurationId_createdAt_idx"
|
||||||
|
ON "vehicle_price_history"("configurationId", "createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "vehicle_pricing_configurations"
|
||||||
|
ADD CONSTRAINT "vehicle_pricing_configurations_vehicleId_fkey"
|
||||||
|
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "vehicle_pricing_rules"
|
||||||
|
ADD CONSTRAINT "vehicle_pricing_rules_configurationId_fkey"
|
||||||
|
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "vehicle_price_history"
|
||||||
|
ADD CONSTRAINT "vehicle_price_history_configurationId_fkey"
|
||||||
|
FOREIGN KEY ("configurationId") REFERENCES "vehicle_pricing_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "vehicle_price_history"
|
||||||
|
ADD CONSTRAINT "vehicle_price_history_vehicleId_fkey"
|
||||||
|
FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -205,6 +205,28 @@ enum FuelType {
|
|||||||
HYBRID
|
HYBRID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum VehiclePricingMode {
|
||||||
|
MANUAL
|
||||||
|
AUTOMATIC
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VehiclePricingRuleType {
|
||||||
|
DATE_RANGE
|
||||||
|
SEASONAL
|
||||||
|
HOLIDAY
|
||||||
|
WEEKEND
|
||||||
|
SPECIAL_EVENT
|
||||||
|
MANUAL_OVERRIDE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VehiclePriceChangeSource {
|
||||||
|
CONFIG_UPDATE
|
||||||
|
RULE_CREATED
|
||||||
|
RULE_UPDATED
|
||||||
|
RULE_DELETED
|
||||||
|
MANUAL_OVERRIDE
|
||||||
|
}
|
||||||
|
|
||||||
enum OfferType {
|
enum OfferType {
|
||||||
PERCENTAGE
|
PERCENTAGE
|
||||||
FIXED_AMOUNT
|
FIXED_AMOUNT
|
||||||
@@ -996,6 +1018,8 @@ model Vehicle {
|
|||||||
maintenance MaintenanceLog[]
|
maintenance MaintenanceLog[]
|
||||||
offerVehicles OfferVehicle[]
|
offerVehicles OfferVehicle[]
|
||||||
calendarBlocks VehicleCalendarBlock[]
|
calendarBlocks VehicleCalendarBlock[]
|
||||||
|
pricingConfiguration VehiclePricingConfiguration?
|
||||||
|
priceHistory VehiclePriceHistory[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1006,6 +1030,80 @@ model Vehicle {
|
|||||||
@@map("vehicles")
|
@@map("vehicles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model VehiclePricingConfiguration {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
vehicleId String @unique
|
||||||
|
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||||
|
pricingMode VehiclePricingMode @default(MANUAL)
|
||||||
|
baseDailyRate Int?
|
||||||
|
weeklyRate Int?
|
||||||
|
weekendRate Int?
|
||||||
|
holidayRate Int?
|
||||||
|
monthlyRate Int?
|
||||||
|
longTermDailyRate Int?
|
||||||
|
minimumDailyRate Int?
|
||||||
|
maximumDailyRate Int?
|
||||||
|
maxDailyPriceMovementPct Int @default(10)
|
||||||
|
automaticPricingEnabled Boolean @default(false)
|
||||||
|
approvalRequired Boolean @default(false)
|
||||||
|
|
||||||
|
rules VehiclePricingRule[]
|
||||||
|
history VehiclePriceHistory[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("vehicle_pricing_configurations")
|
||||||
|
}
|
||||||
|
|
||||||
|
model VehiclePricingRule {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
configurationId String
|
||||||
|
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
|
||||||
|
name String
|
||||||
|
ruleType VehiclePricingRuleType @default(DATE_RANGE)
|
||||||
|
startDate DateTime
|
||||||
|
endDate DateTime
|
||||||
|
dailyRate Int?
|
||||||
|
weeklyRate Int?
|
||||||
|
weekendRate Int?
|
||||||
|
monthlyRate Int?
|
||||||
|
minDailyRate Int?
|
||||||
|
maxDailyRate Int?
|
||||||
|
automaticAdjustmentPct Int?
|
||||||
|
priceAdjustment Int?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([configurationId])
|
||||||
|
@@index([configurationId, startDate, endDate])
|
||||||
|
@@map("vehicle_pricing_rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
model VehiclePriceHistory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
configurationId String
|
||||||
|
configuration VehiclePricingConfiguration @relation(fields: [configurationId], references: [id], onDelete: Cascade)
|
||||||
|
vehicleId String
|
||||||
|
vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade)
|
||||||
|
source VehiclePriceChangeSource @default(CONFIG_UPDATE)
|
||||||
|
changedByEmployeeId String?
|
||||||
|
previousDailyRate Int?
|
||||||
|
nextDailyRate Int?
|
||||||
|
previousWeeklyRate Int?
|
||||||
|
nextWeeklyRate Int?
|
||||||
|
note String?
|
||||||
|
effectiveFrom DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([vehicleId, createdAt])
|
||||||
|
@@index([configurationId, createdAt])
|
||||||
|
@@map("vehicle_price_history")
|
||||||
|
}
|
||||||
|
|
||||||
model Offer {
|
model Offer {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
companyId String
|
companyId String
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
const { spawnSync } = require('node:child_process')
|
const { spawnSync } = require('node:child_process')
|
||||||
const { readdirSync } = require('node:fs')
|
const { readdirSync } = require('node:fs')
|
||||||
const path = require('node:path')
|
const path = require('node:path')
|
||||||
const { ensureDatabaseUrl } = require('../src/runtime-config')
|
const {
|
||||||
|
ensureDatabaseUrl,
|
||||||
|
loadDefaultEnvFiles,
|
||||||
|
normalizeDatabaseUrlForExecution,
|
||||||
|
} = require('../src/runtime-config')
|
||||||
const { PrismaClient } = require('../generated')
|
const { PrismaClient } = require('../generated')
|
||||||
|
|
||||||
const repoRoot = path.resolve(__dirname, '../../..')
|
const repoRoot = path.resolve(__dirname, '../../..')
|
||||||
@@ -24,7 +28,9 @@ function run(command, args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
loadDefaultEnvFiles(repoRoot)
|
||||||
ensureDatabaseUrl()
|
ensureDatabaseUrl()
|
||||||
|
normalizeDatabaseUrlForExecution()
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +109,9 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
|
if (error?.message?.includes("Can't reach database server at `localhost:5432`")) {
|
||||||
|
console.error('[db:deploy] Hint: start the local Postgres service first. In this repo that is usually `docker compose -f docker-compose.dev.yml up -d postgres`.')
|
||||||
|
}
|
||||||
console.error('[db:deploy] Failed:', error)
|
console.error('[db:deploy] Failed:', error)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,96 @@
|
|||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
const ENABLED_VALUES = new Set(['1', 'true', 'yes'])
|
const ENABLED_VALUES = new Set(['1', 'true', 'yes'])
|
||||||
|
|
||||||
|
function parseEnvFile(content) {
|
||||||
|
const env = {}
|
||||||
|
|
||||||
|
for (const rawLine of content.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line || line.startsWith('#')) continue
|
||||||
|
|
||||||
|
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line
|
||||||
|
const separatorIndex = normalized.indexOf('=')
|
||||||
|
if (separatorIndex === -1) continue
|
||||||
|
|
||||||
|
const key = normalized.slice(0, separatorIndex).trim()
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue
|
||||||
|
|
||||||
|
let value = normalized.slice(separatorIndex + 1)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
const quote = value[0]
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
|
||||||
|
if (quote === '"') {
|
||||||
|
value = value
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\r/g, '\r')
|
||||||
|
.replace(/\\t/g, '\t')
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
.replace(/\\\\/g, '\\')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEnvFile(filePath, env = process.env, { override = false } = {}) {
|
||||||
|
if (!fs.existsSync(filePath)) return false
|
||||||
|
const parsed = parseEnvFile(fs.readFileSync(filePath, 'utf8'))
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
if (override || env[key] === undefined) {
|
||||||
|
env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunningInContainer() {
|
||||||
|
return fs.existsSync('/.dockerenv')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDatabaseUrlForExecution(env = process.env) {
|
||||||
|
if (!env.DATABASE_URL) return env.DATABASE_URL
|
||||||
|
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = new URL(env.DATABASE_URL)
|
||||||
|
} catch {
|
||||||
|
return env.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hostname !== 'postgres' || isRunningInContainer()) {
|
||||||
|
return env.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackHost = env.POSTGRES_HOST_LOCAL ?? 'localhost'
|
||||||
|
parsed.hostname = fallbackHost
|
||||||
|
env.DATABASE_URL = parsed.toString()
|
||||||
|
return env.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDefaultEnvFiles(baseDir, env = process.env) {
|
||||||
|
const candidates = [
|
||||||
|
path.join(baseDir, '.env'),
|
||||||
|
path.join(baseDir, '.env.local'),
|
||||||
|
path.join(baseDir, '.env.docker.dev'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
loadEnvFile(candidate, env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDatabaseUrl(env = process.env) {
|
function ensureDatabaseUrl(env = process.env) {
|
||||||
const shouldBuildFromPostgres = ENABLED_VALUES.has(
|
const shouldBuildFromPostgres = ENABLED_VALUES.has(
|
||||||
env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '',
|
env.DATABASE_URL_FROM_POSTGRES?.trim().toLowerCase() ?? '',
|
||||||
@@ -26,4 +117,7 @@ function ensureDatabaseUrl(env = process.env) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ensureDatabaseUrl,
|
ensureDatabaseUrl,
|
||||||
|
loadDefaultEnvFiles,
|
||||||
|
loadEnvFile,
|
||||||
|
normalizeDatabaseUrlForExecution,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user