diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 114c0cc9d..7f09ffa49 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -857,7 +857,7 @@ export class BookingsService { destinationStationId: dto.destinationStationId, status: 'PENDING_PAYMENT', bookingType: 'ONE_WAY', - totalMinor: resolvedTotalMinor / 100, + totalMinor: resolvedTotalMinor, adultCount, childCount, displayCurrency, diff --git a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts index dd4e15b9c..fd3d75401 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/fare-engine.service.ts @@ -103,6 +103,18 @@ export class FareEngineService { }, }); + // Route-level fare override: checked after segment (most specific) but before + // schedule-scoped rules and the global seat-class tariff (least specific). + const routeFareOverride = segmentOverride ? null : await this.prisma.routeFareRule.findFirst({ + where: { + routeId: route.id, + seatClassId: nationalitySeatClass.id, + validFrom: { lte: now }, + OR: [{ validUntil: null }, { validUntil: { gte: now } }], + }, + orderBy: { validFrom: 'desc' }, + }); + if (segmentOverride) { baseFarePerPassengerMinor = segmentOverride.baseFareMinor; if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) { @@ -110,6 +122,20 @@ export class FareEngineService { } ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0; fareSource = 'SEGMENT_FARE_RULE'; + } else if (routeFareOverride) { + // Stored as a per-km rate (same unit as SeatClass.baseFareMinor × 100). + // Insurance factor and USD→ETB conversion are applied identically to the + // global seat-class formula so the override is a pure rate substitution. + const ratePerKmEtb = routeFareOverride.baseFareMinor / 100; + insuranceFactor = nationalitySeatClass.insuranceFeeMinor > 0 + ? nationalitySeatClass.insuranceFeeMinor / 100 : 1; + usdToEtbRate = await this.currencyService.getExchangeRate(Currency.USD, Currency.ETB); + ratePerKmMinor = routeFareOverride.baseFareMinor; + baseFarePerPassengerMinor = Math.round( + totalDistanceKm * ratePerKmEtb * insuranceFactor * usdToEtbRate, + ); + fareSource = 'ROUTE_FARE_OVERRIDE'; + insuranceAlreadyInBase = true; } else if (fareRule?.tripId) { baseFarePerPassengerMinor = fareRule.baseFareMinor; if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) { @@ -172,7 +198,7 @@ export class FareEngineService { const calculation = [ `Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`, `Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.name}`, - `Rate per km: ${nationalitySeatClass.baseFareMinor} minor → ${nationalitySeatClass.baseFareMinor / 100} ETB/km`, + `Rate per km: ${routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor} minor → ${(routeFareOverride ? routeFareOverride.baseFareMinor : nationalitySeatClass.baseFareMinor) / 100} ETB/km${routeFareOverride ? ' [ROUTE OVERRIDE]' : ''}`, `Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`, `USD→ETB rate: ${usdToEtbRate}`, `Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 9fc2ba851..cbd2eb4b2 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -69,6 +69,40 @@ export class SchedulesController { @ApiOperation({ summary: 'Create a segment fare rule' }) createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); } + // Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules' + @Delete('routes/fare-rules/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete a route-level fare override' }) + @ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) + deleteRouteFareRule(@Param('id') id: string) { + return this.service.deleteRouteFareRule(id); + } + + @Patch('routes/fare-rules/:id') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update a route-level fare override' }) + @ApiParam({ name: 'id', description: 'RouteFareRule UUID' }) + updateRouteFareRule(@Param('id') id: string, @Body() dto: any) { + return this.service.updateRouteFareRule(id, dto); + } + + @Get('routes/:routeId/fare-rules') + @IsPublic() + @ApiOperation({ summary: 'List route-level fare overrides for a route' }) + @ApiParam({ name: 'routeId', description: 'Route UUID' }) + listRouteFareRules(@Param('routeId') routeId: string) { + return this.service.listRouteFareRules(routeId); + } + + @Post('routes/:routeId/fare-rules') + @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a route-level fare override' }) + @ApiParam({ name: 'routeId', description: 'Route UUID' }) + createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) { + return this.service.createRouteFareRule({ ...dto, routeId }); + } + @Get('routes/:routeId/segment-fares') @IsPublic() @ApiOperation({ summary: 'List all segment fare rules for a route' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 92a0a66db..06bb4c2dc 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -676,4 +676,63 @@ export class SchedulesService { await this.prisma.coachAssignment.delete({ where: { id: assignment.id } }); return { message: 'Coach assignment removed' }; } + + // ── Route Fare Rule Overrides ────────────────────────────────────────────── + + listRouteFareRules(routeId: string) { + return this.prisma.routeFareRule.findMany({ + where: { routeId }, + include: { seatClass: true, route: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async createRouteFareRule(dto: { + routeId: string; + seatClassId: string; + passengerCategory?: string; + baseFareMinor: number; + validFrom: string; + validUntil?: string; + }) { + const [route, seatClass] = await Promise.all([ + this.prisma.route.findUnique({ where: { id: dto.routeId } }), + this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } }), + ]); + if (!route) throw new NotFoundException('Route not found'); + if (!seatClass) throw new NotFoundException('Seat class not found'); + return this.prisma.routeFareRule.create({ + data: { + routeId: dto.routeId, + seatClassId: dto.seatClassId, + passengerCategory: (dto.passengerCategory as any) ?? 'ADULT', + baseFareMinor: dto.baseFareMinor, + validFrom: parseEthiopianTime(dto.validFrom), + validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null, + }, + include: { seatClass: true, route: true }, + }); + } + + async updateRouteFareRule(id: string, dto: { baseFareMinor?: number; surchargeMinor?: number; validFrom?: string; validUntil?: string }) { + const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); + if (!rule) throw new NotFoundException('Route fare rule not found'); + return this.prisma.routeFareRule.update({ + where: { id }, + data: { + ...(dto.baseFareMinor !== undefined && { baseFareMinor: dto.baseFareMinor }), + ...(dto.surchargeMinor !== undefined && { surchargeMinor: dto.surchargeMinor }), + ...(dto.validFrom && { validFrom: parseEthiopianTime(dto.validFrom) }), + ...(dto.validUntil !== undefined && { validUntil: dto.validUntil ? parseEthiopianTime(dto.validUntil) : null }), + }, + include: { seatClass: true, route: true }, + }); + } + + async deleteRouteFareRule(id: string) { + const rule = await this.prisma.routeFareRule.findUnique({ where: { id } }); + if (!rule) throw new NotFoundException('Route fare rule not found'); + await this.prisma.routeFareRule.delete({ where: { id } }); + return { deleted: true, id }; + } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 4eb6c212b..f6cf3c76a 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SeatClassesService } from './seat-classes.service'; @@ -49,5 +49,7 @@ export class SeatClassesController { @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiResponse({ status: 200, description: 'Seat class deleted' }) @ApiResponse({ status: 404, description: 'Seat class not found' }) - deleteSeatClass(@Param('id') id: string) { return this.service.deleteSeatClass(id); } + deleteSeatClass(@Param('id') id: string, @Query('cascade') cascade?: string) { + return this.service.deleteSeatClass(id, cascade === 'true'); + } } diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts index 63f5e1f29..99c67a665 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.service.ts @@ -45,7 +45,7 @@ export class SeatClassesService { } } - async deleteSeatClass(id: string) { + async deleteSeatClass(id: string, cascade = false) { const sc = await this.prisma.seatClass.findUnique({ where: { id }, include: { @@ -59,11 +59,17 @@ export class SeatClassesService { (sc as any)._count.routeFareRules + (sc as any)._count.segmentFares; - if (totalFareRules > 0) + if (totalFareRules > 0 && !cascade) throw new DeleteOperationException('Seat Class', sc.name, [ { entityName: 'fare rule', count: totalFareRules, action: 'delete' }, ]); + if (cascade) { + await this.prisma.fareRule.deleteMany({ where: { seatClassId: id } }); + await this.prisma.routeFareRule.deleteMany({ where: { seatClassId: id } }); + await this.prisma.segmentFareRule.deleteMany({ where: { seatClassId: id } }); + } + return this.prisma.seatClass.delete({ where: { id } }); } } diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index f5aed275e..af2cab29b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -311,7 +311,6 @@ export default function ClassesPage() { step="0.01" placeholder="e.g., 25.00" /> -

Flat fee per passenger (e.g., travel insurance)

diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx new file mode 100644 index 000000000..5723197c9 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/BaggageTab.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useState } from 'react'; +import { Edit, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { useBaggageMutations } from './hooks'; +import type { SeatClass, BaggageAllowance } from './types'; + +interface Props { + allClasses: SeatClass[]; + isOpen: boolean; + onClose: () => void; +} + +export default function BaggageTab({ allClasses, isOpen, onClose }: Props) { + const { allowances, isLoading, create, update, remove } = useBaggageMutations(); + const [form, setForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); + const [editing, setEditing] = useState(null); + const [error, setError] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null }); + + const resetForm = () => { setForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); setEditing(null); setError(null); }; + + const handleSave = async () => { + setError(null); + if (!form.seatClassId || !form.maxWeightKg || !form.maxPiecesCount || !form.excessFeePerKg) { + setError('All fields are required'); return; + } + const payload = { + seatClassId: form.seatClassId, + maxWeightKg: parseInt(form.maxWeightKg), + maxPiecesCount: parseInt(form.maxPiecesCount), + excessFeePerKg: Math.round(parseFloat(form.excessFeePerKg) * 100), + }; + try { + if (editing) { + await update.mutateAsync({ id: editing.id, ...payload }); + } else { + await create.mutateAsync(payload); + } + resetForm(); + onClose(); + } catch (e: any) { + setError(e?.response?.data?.message ?? 'Failed to save'); + } + }; + + return ( + <> + {isLoading ? ( +
+
+
+ ) : ( + {a.seatClass?.name ?? a.seatClassId} }, + { key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, + { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => {(a.excessFeePerKg / 100).toFixed(2)} ETB }, + ]} + actions={[ + { + label: 'Edit', icon: Edit, variant: 'secondary' as const, + onClick: (a: BaggageAllowance) => { + setEditing(a); + setForm({ seatClassId: a.seatClassId, maxWeightKg: String(a.maxWeightKg), maxPiecesCount: String(a.maxPiecesCount), excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2) }); + setError(null); + }, + }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (a: BaggageAllowance) => setDeleteConfirm({ isOpen: true, id: a.id }) }, + ]} + loading={false} + emptyMessage='No baggage allowance rules defined. Click "Add Allowance Rule" to create one.' + /> + )} + + { resetForm(); onClose(); }} + title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'} + size="md" + > +
+ {error &&
{error}
} +
+ + + {editing &&

Seat class cannot be changed. Delete and recreate to change.

} +
+
+
+ + setForm({ ...form, maxWeightKg: e.target.value })} /> +
+
+ + setForm({ ...form, maxPiecesCount: e.target.value })} /> +
+
+
+ + setForm({ ...form, excessFeePerKg: e.target.value })} /> +

Amount charged per kg above the free allowance

+
+
+ { resetForm(); onClose(); }}>Cancel + + {editing ? 'Update' : 'Save'} + +
+
+
+ + setDeleteConfirm({ isOpen: false, id: null })} + onConfirm={() => remove.mutate(deleteConfirm.id!)} + title="Delete Allowance Rule" + message="Are you sure you want to delete this baggage allowance rule?" + confirmText="Delete" + isDanger + isLoading={remove.isPending} + warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created." + /> + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx new file mode 100644 index 000000000..abdb0887f --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/OverridesTab.tsx @@ -0,0 +1,272 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, Edit, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import Modal from '@/components/ui/Modal'; +import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks'; +import type { Route, RouteFareRule, SeatClass } from './types'; + +interface Props { + routes: Route[]; +} + +type OverrideForm = { + isOpen: boolean; + rule: RouteFareRule | null; // null = add mode + error: string | null; +}; + +export default function OverridesTab({ routes }: Props) { + const [selectedRouteId, setSelectedRouteId] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ + isOpen: boolean; + id: string | null; + name: string; + cascade: boolean; + cascadeChecked: boolean; + error?: string; + }>({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false }); + const [form, setForm] = useState({ isOpen: false, rule: null, error: null }); + + const { overrides, isLoading } = useRouteFareRules(selectedRouteId); + const { update, remove, create } = useRouteFareRuleMutations(selectedRouteId); + const { allClasses } = useSeatClasses(); + + const handleDeleteClick = (r: RouteFareRule) => { + setDeleteConfirm({ + isOpen: true, id: r.id, + name: r.seatClass?.name ?? r.seatClassId, + cascade: false, cascadeChecked: false, error: undefined, + }); + }; + + const handleConfirmDelete = async () => { + try { + await remove.mutateAsync(deleteConfirm.id!); + setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false }); + } catch (err: any) { + const msg = err?.response?.data?.message ?? err?.message ?? 'Delete failed'; + const isFkError = msg.includes('Cannot delete') || err?.response?.status === 400; + setDeleteConfirm(prev => ({ + ...prev, + cascade: isFkError && !prev.cascade ? true : prev.cascade, + cascadeChecked: false, + error: msg, + })); + } + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setForm(prev => ({ ...prev, error: null })); + const fd = new FormData(e.currentTarget); + const baseFareMinor = Math.round(Number(fd.get('baseFareMinor')) * 100) || 0; + const surchargeMinor = Math.round(Number(fd.get('surchargeMinor') ?? '0') * 100) || 0; + try { + if (form.rule) { + await update.mutateAsync({ id: form.rule.id, baseFareMinor, surchargeMinor }); + } else { + const routeId = fd.get('routeId') as string; + const seatClassId = fd.get('seatClassId') as string; + await create.mutateAsync({ + routeId, + seatClassId, + passengerCategory: 'ADULT', + baseFareMinor, + surchargeMinor, + validFrom: new Date().toISOString(), + }); + } + setForm({ isOpen: false, rule: null, error: null }); + } catch (err: any) { + setForm(prev => ({ ...prev, error: err?.response?.data?.message ?? err?.message ?? 'Failed to save' })); + } + }; + + const columns = [ + { + key: 'route', label: 'Route', + render: (r: RouteFareRule) => {r.route?.name ?? r.routeId}, + }, + { + key: 'seatClass', label: 'Seat Class', + render: (r: RouteFareRule) => {r.seatClass?.name ?? r.seatClassId}, + }, + { + key: 'passengerCategory', label: 'Category', + render: (r: RouteFareRule) => ( + + {r.passengerCategory} + + ), + }, + { + key: 'baseFareMinor', label: 'Rate per km', + render: (r: RouteFareRule) => {r.baseFareMinor / 100}, + }, + { + key: 'surchargeMinor', label: 'Insurance Fee', + render: (r: RouteFareRule) => ( + + {r.surchargeMinor ? (r.surchargeMinor / 100).toFixed(2) : '0.00'} ETB + + ), + }, + { + key: 'validFrom', label: 'Valid From', + render: (r: RouteFareRule) => {new Date(r.validFrom).toLocaleDateString()}, + }, + { + key: 'validUntil', label: 'Valid Until', + render: (r: RouteFareRule) => ( + {r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'} + ), + }, + ]; + + const isAddMode = form.isOpen && !form.rule; + const isPending = create.isPending || update.isPending; + + return ( + <> +
+ + + setForm({ isOpen: true, rule: null, error: null })} + > + Add Override + +
+ + {!selectedRouteId ? ( +
+ Select a route above to view its fare overrides. +
+ ) : ( + setForm({ isOpen: true, rule: r, error: null }), + }, + { label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick }, + ]} + loading={isLoading} + emptyMessage="No overrides for this route. Click 'Add Override' to create one." + /> + )} + + {/* Add / Edit modal */} + setForm({ isOpen: false, rule: null, error: null })} + title={isAddMode ? 'Add Route Override' : 'Edit Route Override'} + size="md" + > +
+ {form.error && ( +
+ {form.error} +
+ )} + + {isAddMode ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+ {form.rule?.seatClass?.name ?? form.rule?.seatClassId} + {' · '}{form.rule?.route?.name ?? form.rule?.routeId} + {' · '}{form.rule?.passengerCategory} +
+ )} + +
+
+ + +
+
+ + +
+
+ +
+ setForm({ isOpen: false, rule: null, error: null })}>Cancel + + {isAddMode ? 'Create Override' : 'Update Override'} + +
+
+
+ + setDeleteConfirm({ isOpen: false, id: null, name: '', cascade: false, cascadeChecked: false })} + onConfirm={handleConfirmDelete} + title="Delete Route Override" + message={`Delete the fare override for "${deleteConfirm.name}"? The global seat class rate will apply instead.`} + confirmText="Delete" + isDanger + isLoading={remove.isPending} + error={deleteConfirm.error} + warning={!deleteConfirm.cascade + ? 'Removing this override means all future bookings on this route will fall back to the global tariff rate.' + : undefined} + cascadeWarning={deleteConfirm.cascade + ? 'This override has related records that will also be permanently deleted.' + : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={checked => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} + /> + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx new file mode 100644 index 000000000..aaa676001 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/RateModal.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Modal from '@/components/ui/Modal'; +import ActionButton from '@/components/ui/ActionButton'; +import { BED_POSITIONS, COACH_TYPE_LABELS, getTariffRef } from './constants'; +import type { SeatClass, CoachType, Route } from './types'; + +interface Props { + isOpen: boolean; + onClose: () => void; + editingClass: SeatClass | null; + allClasses: SeatClass[]; + coachTypes: CoachType[]; + routes?: Route[]; + preselectedRouteId?: string | null; + allowRouteOverride?: boolean; + onSubmitGlobal: (payload: any) => Promise; + onSubmitOverride: (routeId: string, payload: any) => Promise; + isPending: boolean; +} + +export default function RateModal({ + isOpen, onClose, editingClass, allClasses, coachTypes, routes, + preselectedRouteId, allowRouteOverride, onSubmitGlobal, onSubmitOverride, isPending, +}: Props) { + const [nationalityType, setNationalityType] = useState('LOCAL'); + const [coachTypeId, setCoachTypeId] = useState(''); + const [bedPosition, setBedPosition] = useState(''); + const [routeId, setRouteId] = useState(preselectedRouteId ?? ''); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setNationalityType(editingClass?.nationalityType ?? 'LOCAL'); + setCoachTypeId(editingClass?.coachTypeId ?? ''); + setBedPosition(editingClass?.bedPosition ?? ''); + setRouteId(preselectedRouteId ?? ''); + setError(null); + }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps + + const selectedCoachType = coachTypes.find(c => c.id === coachTypeId) ?? (editingClass as any)?.coachType; + const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || + selectedCoachType?.code?.toLowerCase().includes('bed'); + + const suggestName = () => { + if (!selectedCoachType) return ''; + const label = COACH_TYPE_LABELS[selectedCoachType.code] ?? selectedCoachType.name; + const pos = bedPosition ? ` ${bedPosition.charAt(0) + bedPosition.slice(1).toLowerCase()}` : ''; + const nat = nationalityType === 'LOCAL' ? 'Local' : 'Intl'; + return `${label}${pos} (${nat})`; + }; + + const suggestRate = () => { + if (!selectedCoachType) return ''; + const ref = getTariffRef(nationalityType, selectedCoachType.code, bedPosition || null); + return ref ? String(ref) : ''; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const fd = new FormData(e.currentTarget); + + try { + if (routeId && !editingClass) { + const matched = allClasses.find(c => + c.coachTypeId === coachTypeId && + c.nationalityType === nationalityType && + (c.bedPosition ?? null) === (bedPosition || null), + ); + if (!matched) { + setError('No matching seat class found for the selected combination. Create the global rate first.'); + return; + } + await onSubmitOverride(routeId, { + seatClassId: matched.id, + passengerCategory: 'ADULT', + baseFareMinor: Math.round(Number(fd.get('baseFareMinor')) * 100) || 0, + surchargeMinor: Math.round(Number(fd.get('surchargeMinor') ?? '0') * 100) || 0, + validFrom: new Date().toISOString(), + }); + } else { + await onSubmitGlobal({ + coachTypeId, + name: fd.get('name') as string, + nationalityType, + bedPosition: bedPosition || null, + basePrice: Math.round(Number(fd.get('baseFareMinor')) * 100) || 0, + insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') ?? '0') * 100) || 0, + isActive: fd.get('isActive') === 'true', + }); + } + onClose(); + } catch (err: any) { + setError(err?.response?.data?.message ?? err?.message ?? 'Failed to save'); + } + }; + + const isOverrideMode = !!routeId && !editingClass; + const title = editingClass ? 'Edit Tariff Rate' : isOverrideMode ? 'Add Route Override' : 'Add Tariff Rate'; + + return ( + +
+ {error && ( +
+ {error} +
+ )} + + {!editingClass && allowRouteOverride && ( +
+ + + {isOverrideMode && ( +

+ Override applies only to this route. A matching global seat class must already exist. +

+ )} +
+ )} + +
+
+ + +
+ +
+ + +
+ + {isBedCoach && ( +
+ + +

+ {selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'} +

+
+ )} + + {!isOverrideMode && ( +
+ + + {!editingClass && suggestName() && ( +

+ Suggested:{' '} + +

+ )} +
+ )} + +
+ + + {suggestRate() && ( +

+ Official tariff:{' '} + + {' '}(stored as {Math.round(Number(suggestRate()) * 100)}) +

+ )} +
+ + {/* Insurance fee — different field name depending on mode */} +
+ + +
+ + {!isOverrideMode && ( +
+ + +
+ )} +
+ +
+ Cancel + + {editingClass ? 'Update Rate' : isOverrideMode ? 'Create Override' : 'Create Rate'} + +
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx new file mode 100644 index 000000000..63c9b5d38 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/TariffTab.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Edit, Trash2, Search } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { getTariffRef } from './constants'; +import type { SeatClass, CoachType } from './types'; + +interface Props { + classes: SeatClass[]; + coachTypes: CoachType[]; + isLoading: boolean; + onEdit: (cls: SeatClass) => void; + onDelete: (id: string, cascade: boolean) => void; + isDeleting: boolean; + deleteError?: string; + deleteSuccess?: number; +} + +export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) { + const [search, setSearch] = useState(''); + const [deleteConfirm, setDeleteConfirm] = useState<{ + isOpen: boolean; + item: SeatClass | null; + cascade: boolean; + cascadeChecked: boolean; + error?: string; + }>({ isOpen: false, item: null, cascade: false, cascadeChecked: false }); + + // Close dialog on successful delete + useEffect(() => { + if (!deleteSuccess) return; + setDeleteConfirm({ isOpen: false, item: null, cascade: false, cascadeChecked: false }); + }, [deleteSuccess]); // eslint-disable-line react-hooks/exhaustive-deps + + // Sync external error into dialog when it arrives + useEffect(() => { + if (!deleteError || !deleteConfirm.isOpen) return; + setDeleteConfirm(prev => ({ + ...prev, + cascade: prev.cascade || deleteError.includes('Cannot delete'), + cascadeChecked: false, + error: deleteError, + })); + }, [deleteError]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleDeleteClick = (cls: SeatClass) => { + setDeleteConfirm({ isOpen: true, item: cls, cascade: false, cascadeChecked: false, error: undefined }); + }; + + const handleConfirm = () => { + onDelete(deleteConfirm.item!.id, deleteConfirm.cascade && deleteConfirm.cascadeChecked); + }; + + const displayed = classes + .filter(c => c.nationalityType) + .filter(c => { + if (!search) return true; + const s = search.toLowerCase(); + const ct = coachTypes.find(t => t.id === c.coachTypeId); + return ( + c.name?.toLowerCase().includes(s) || + c.nationalityType?.toLowerCase().includes(s) || + c.bedPosition?.toLowerCase().includes(s) || + ct?.name?.toLowerCase().includes(s) + ); + }) + .sort((a, b) => (a.nationalityType === b.nationalityType ? 0 : a.nationalityType === 'LOCAL' ? -1 : 1)); + + const columns = [ + { + key: 'nationalityType', label: 'Passenger Type', + render: (c: SeatClass) => ( + + {c.nationalityType === 'LOCAL' ? 'Local' : 'International'} + + ), + }, + { + key: 'coachType', label: 'Coach Type', + render: (c: SeatClass) => { + const ct = coachTypes.find(t => t.id === c.coachTypeId); + return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}; + }, + }, + { + key: 'name', label: 'Class Name', + render: (c: SeatClass) => {c.name}, + }, + { + key: 'baseFareMinor', label: 'Rate per km', + render: (c: SeatClass) => { + const ct = coachTypes.find(t => t.id === c.coachTypeId); + const ref = ct ? getTariffRef(c.nationalityType!, ct.code, c.bedPosition ?? null) : undefined; + const tariffMinor = ref ? Math.round(ref * 100) : undefined; + const matches = tariffMinor === c.baseFareMinor; + return ( +
+ {c.baseFareMinor! / 100} + {tariffMinor !== undefined && ( + + {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`} + + )} +
+ ); + }, + }, + { + key: 'insuranceFeeMinor', label: 'Insurance Fee', + render: (c: SeatClass) => ( + {c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB + ), + }, + { + key: 'isActive', label: 'Status', + render: (c: SeatClass) => ( + + {c.isActive ? 'Active' : 'Inactive'} + + ), + }, + ]; + + return ( + <> +
+ + setSearch(e.target.value)} + /> +
+ + + + setDeleteConfirm({ isOpen: false, item: null, cascade: false, cascadeChecked: false })} + onConfirm={handleConfirm} + title="Delete Tariff Rate" + message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`} + confirmText="Delete" + isDanger + isLoading={isDeleting} + error={deleteConfirm.error} + warning={!deleteConfirm.cascade + ? 'This seat class may be referenced by fare rules, route overrides, and segment fares. Deleting it will impact pricing across all routes.' + : undefined} + cascadeWarning={deleteConfirm.cascade + ? 'This seat class has related fare rules, route overrides, or segment fares that will also be permanently deleted.' + : undefined} + cascadeChecked={deleteConfirm.cascadeChecked} + onCascadeChange={checked => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} + /> + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts new file mode 100644 index 000000000..f34a80b69 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/constants.ts @@ -0,0 +1,31 @@ +export const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const; + +export const COACH_TYPE_LABELS: Record = { + HSC: 'Regular Seat (Hard Seat)', + HBC: 'Economy Bed (Hard Berth)', + SBC: 'VIP Bed (Soft Berth)', +}; + +export const TARIFF_REFERENCE: Record> = { + LOCAL: { + 'HSC-null': 0.03, + 'HBC-UPPER': 0.04, + 'HBC-MIDDLE': 0.055, + 'HBC-LOWER': 0.06, + 'SBC-UPPER': 0.075, + 'SBC-LOWER': 0.08, + }, + INTERNATIONAL: { + 'HSC-null': 0.06, + 'HBC-UPPER': 0.08, + 'HBC-MIDDLE': 0.11, + 'HBC-LOWER': 0.12, + 'SBC-UPPER': 0.15, + 'SBC-LOWER': 0.16, + }, +}; + +export function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) { + const key = `${coachCode}-${bedPosition ?? 'null'}`; + return TARIFF_REFERENCE[nationalityType]?.[key]; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts new file mode 100644 index 000000000..cd6f6b5b4 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts @@ -0,0 +1,106 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; +import type { SeatClass, CoachType, Route, RouteFareRule, BaggageAllowance } from './types'; + +function toArray(data: unknown): T[] { + if (Array.isArray(data)) return data as T[]; + const d = data as any; + return d?.items ?? d?.data ?? []; +} + +export function useSeatClasses() { + const { data, isLoading } = useQuery({ + queryKey: ['seat-classes'], + queryFn: () => apiClient.get('/seat-classes'), + }); + return { allClasses: toArray(data), isLoading }; +} + +export function useCoachTypes() { + const { data } = useQuery({ + queryKey: ['coach-types'], + queryFn: () => apiClient.get('/fleet/coach-types'), + }); + return { coachTypes: toArray(data) }; +} + +export function useRoutes() { + const { data } = useQuery({ + queryKey: ['routes-active'], + queryFn: () => apiClient.get('/routes?activeOnly=true'), + }); + return { routes: toArray(data) }; +} + +export function useRouteFareRules(routeId: string | null) { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['route-fare-rules', routeId], + queryFn: () => apiClient.get(`/schedules/routes/${routeId}/fare-rules`), + enabled: !!routeId, + }); + return { overrides: toArray(data), isLoading, refetch }; +} + +export function useSeatClassMutations(onSuccess: () => void) { + const queryClient = useQueryClient(); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); + + const create = useMutation({ + mutationFn: (data: any) => apiClient.post('/seat-classes', data), + onSuccess: () => { invalidate(); onSuccess(); }, + }); + const update = useMutation({ + mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data), + onSuccess: () => { invalidate(); onSuccess(); }, + }); + const remove = useMutation({ + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => + apiClient.delete(`/seat-classes/${id}${cascade ? '?cascade=true' : ''}`), + onSuccess: invalidate, + }); + + return { create, update, remove }; +} + +export function useRouteFareRuleMutations(routeId: string | null) { + const queryClient = useQueryClient(); + const invalidate = (rid?: string) => + queryClient.invalidateQueries({ queryKey: ['route-fare-rules', rid ?? routeId] }); + + const create = useMutation({ + mutationFn: ({ routeId: rid, ...data }: any) => apiClient.post(`/schedules/routes/${rid}/fare-rules`, data), + onSuccess: (_: any, vars: any) => invalidate(vars.routeId), + }); + const update = useMutation({ + mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/routes/fare-rules/${id}`, data), + onSuccess: invalidate, + }); + const remove = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/schedules/routes/fare-rules/${id}`), + onSuccess: invalidate, + }); + + return { create, update, remove }; +} + +export function useBaggageMutations() { + const { data, isLoading, refetch } = useQuery({ + queryKey: ['baggage-allowances'], + queryFn: () => apiClient.get('/agents/excess-baggage/allowances'), + }); + + const create = useMutation({ + mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data), + onSuccess: () => refetch(), + }); + const update = useMutation({ + mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data), + onSuccess: () => refetch(), + }); + const remove = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`), + onSuccess: () => refetch(), + }); + + return { allowances: toArray(data), isLoading, create, update, remove }; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 530b335e7..aef643b06 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -1,285 +1,64 @@ 'use client'; import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Trash2, Search } from 'lucide-react'; -import DataTable from '@/components/ui/DataTable'; -import Badge from '@/components/ui/Badge'; +import { Plus } from 'lucide-react'; import ActionButton from '@/components/ui/ActionButton'; -import Modal from '@/components/ui/Modal'; -import ConfirmDialog from '@/components/ui/ConfirmDialog'; -import { apiClient } from '@/lib/api-client'; - -interface SeatClass { id: string; name: string; } - -const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const; -const COACH_TYPE_LABELS: Record = { - HSC: 'Regular Seat (Hard Seat)', - HBC: 'Economy Bed (Hard Berth)', - SBC: 'VIP Bed (Soft Berth)', -}; - -const TARIFF_REFERENCE: Record> = { - LOCAL: { - 'HSC-null': 0.03, - 'HBC-UPPER': 0.04, - 'HBC-MIDDLE': 0.055, - 'HBC-LOWER': 0.06, - 'SBC-UPPER': 0.075, - 'SBC-LOWER': 0.08, - }, - INTERNATIONAL: { - 'HSC-null': 0.06, - 'HBC-UPPER': 0.08, - 'HBC-MIDDLE': 0.11, - 'HBC-LOWER': 0.12, - 'SBC-UPPER': 0.15, - 'SBC-LOWER': 0.16, - }, -}; - -function getTariffRef(nationalityType: string, coachCode: string, bedPosition: string | null) { - const key = `${coachCode}-${bedPosition ?? 'null'}`; - return TARIFF_REFERENCE[nationalityType]?.[key]; -} +import TariffTab from './TariffTab'; +import OverridesTab from './OverridesTab'; +import BaggageTab from './BaggageTab'; +import RateModal from './RateModal'; +import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks'; +import type { SeatClass, TabType } from './types'; export default function TariffRatesPage() { - const [tab, setTab] = useState<'tariff' | 'baggage'>('tariff'); - const [search, setSearch] = useState(''); - const [showModal, setShowModal] = useState(false); - const [editingClass, setEditingClass] = useState(null); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); - const [formError, setFormError] = useState(null); - const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); - const [selectedBedPosition, setSelectedBedPosition] = useState(''); - const [selectedNationalityType, setSelectedNationalityType] = useState('LOCAL'); + const [tab, setTab] = useState('tariff'); + const [showRateModal, setShowRateModal] = useState(false); + const [editingClass, setEditingClass] = useState(null); + const [showBaggageModal, setShowBaggageModal] = useState(false); + const [preselectedRouteId, setPreselectedRouteId] = useState(null); + const [deleteError, setDeleteError] = useState(undefined); + const [deleteSuccess, setDeleteSuccess] = useState(0); - const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); - const [editingAllowance, setEditingAllowance] = useState(null); - const [baggageError, setBaggageError] = useState(null); - const [baggageModal, setBaggageModal] = useState(false); - const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null }); + const { allClasses, isLoading } = useSeatClasses(); + const { coachTypes } = useCoachTypes(); + const { routes } = useRoutes(); - const queryClient = useQueryClient(); + const closeRateModal = () => { setShowRateModal(false); setEditingClass(null); setPreselectedRouteId(null); }; - const { data: allowances, isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({ - queryKey: ['baggage-allowances'], - queryFn: () => apiClient.get('/agents/excess-baggage/allowances'), - enabled: tab === 'baggage', - }); + const seatClassMutations = useSeatClassMutations(closeRateModal); + const overrideMutations = useRouteFareRuleMutations(preselectedRouteId); - const createAllowanceMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/agents/excess-baggage/allowances', data), - onSuccess: () => { refetchAllowances(); setBaggageModal(false); setBaggageError(null); }, - onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to save'), - }); - - const updateAllowanceMutation = useMutation({ - mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/excess-baggage/allowances/${id}`, data), - onSuccess: () => { refetchAllowances(); setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }, - onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to update'), - }); - - const deleteAllowanceMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/agents/excess-baggage/allowances/${id}`), - onSuccess: () => { refetchAllowances(); setDeleteAllowanceConfirm({ isOpen: false, id: null }); }, - onError: (e: any) => setBaggageError(e?.response?.data?.message || 'Failed to delete'), - }); - - const handleSaveAllowance = async () => { - setBaggageError(null); - if (!baggageForm.seatClassId || !baggageForm.maxWeightKg || !baggageForm.maxPiecesCount || !baggageForm.excessFeePerKg) { - setBaggageError('All fields are required'); return; - } - const payload = { - seatClassId: baggageForm.seatClassId, - maxWeightKg: parseInt(baggageForm.maxWeightKg), - maxPiecesCount: parseInt(baggageForm.maxPiecesCount), - excessFeePerKg: Math.round(parseFloat(baggageForm.excessFeePerKg) * 100), - }; - if (editingAllowance) { - await updateAllowanceMutation.mutateAsync({ id: editingAllowance.id, ...payload }); - } else { - await createAllowanceMutation.mutateAsync(payload); - } - }; - - const { data: classesData, isLoading } = useQuery({ - queryKey: ['seat-classes'], - queryFn: () => apiClient.get('/seat-classes'), - }); - - const { data: coachTypesData } = useQuery({ - queryKey: ['coach-types'], - queryFn: () => apiClient.get('/fleet/coach-types'), - }); - - const createMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/seat-classes', data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); }, - onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to save'), - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: any }) => apiClient.patch(`/seat-classes/${id}`, data), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); closeModal(); }, - onError: (e: any) => setFormError(e?.response?.data?.message || e?.message || 'Failed to update'), - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => apiClient.delete(`/seat-classes/${id}`), - onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['seat-classes'] }); setDeleteConfirm({ isOpen: false, item: null }); }, - onError: (e: any) => setDeleteConfirm(prev => ({ ...prev, error: e?.response?.data?.message || e?.message || 'Delete failed' })), - }); - - const closeModal = () => { - setShowModal(false); - setEditingClass(null); - setFormError(null); - setSelectedCoachTypeId(''); - setSelectedBedPosition(''); - setSelectedNationalityType('LOCAL'); - }; - - const openEdit = (cls: any) => { - setEditingClass(cls); - setSelectedCoachTypeId(cls.coachTypeId || ''); - setSelectedBedPosition(cls.bedPosition || ''); - setSelectedNationalityType(cls.nationalityType || 'LOCAL'); - setFormError(null); - setShowModal(true); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setFormError(null); - const fd = new FormData(e.currentTarget); - const payload: any = { - coachTypeId: selectedCoachTypeId, - name: fd.get('name') as string, - nationalityType: selectedNationalityType, - bedPosition: selectedBedPosition || null, - basePrice: Math.round(Number(fd.get('baseFareMinor') as string) * 100) || 0, - insuranceFeeMinor: Math.round(Number(fd.get('insuranceFeeMinor') as string) * 100) || 0, - isActive: fd.get('isActive') === 'true', - }; + const handleSubmitGlobal = async (payload: any) => { if (editingClass) { - await updateMutation.mutateAsync({ id: editingClass.id, data: payload }); + await seatClassMutations.update.mutateAsync({ id: editingClass.id, data: payload }); } else { - await createMutation.mutateAsync(payload); + await seatClassMutations.create.mutateAsync(payload); + } + closeRateModal(); + }; + + const handleSubmitOverride = async (routeId: string, payload: any) => { + await overrideMutations.create.mutateAsync({ routeId, ...payload }); + closeRateModal(); + }; + + const handleDelete = async (id: string, cascade: boolean) => { + setDeleteError(undefined); + try { + await seatClassMutations.remove.mutateAsync({ id, cascade }); + setDeleteSuccess(n => n + 1); + } catch (err: any) { + const msg = err?.response?.data?.message ?? err?.message ?? 'Delete failed'; + setDeleteError(Array.isArray(msg) ? msg.join(' ') : msg); } }; - const coachTypesArray: any[] = Array.isArray(coachTypesData) - ? coachTypesData - : (coachTypesData as any)?.data || (coachTypesData as any)?.items || []; - - const allClasses: any[] = Array.isArray(classesData) - ? classesData - : (classesData as any)?.items || (classesData as any)?.data || []; - - const allowancesArray: any[] = Array.isArray(allowances) ? allowances : (allowances as any)?.items || []; - - const tariffClasses = allClasses.filter((c: any) => c.nationalityType); - - const displayed = tariffClasses.filter((c: any) => { - if (!search) return true; - const s = search.toLowerCase(); - return ( - c.name?.toLowerCase().includes(s) || - c.nationalityType?.toLowerCase().includes(s) || - c.bedPosition?.toLowerCase().includes(s) || - c.coachType?.name?.toLowerCase().includes(s) - ); - }).sort((a: any, b: any) => { - if (a.nationalityType === b.nationalityType) return 0; - return a.nationalityType === 'LOCAL' ? -1 : 1; - }); - - const suggestName = () => { - const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); - if (!ct) return ''; - const label = COACH_TYPE_LABELS[ct.code] || ct.name; - const pos = selectedBedPosition ? ` ${selectedBedPosition.charAt(0) + selectedBedPosition.slice(1).toLowerCase()}` : ''; - const nat = selectedNationalityType === 'LOCAL' ? 'Local' : 'Intl'; - return `${label}${pos} (${nat})`; - }; - - // Returns the human-readable rate (e.g. 0.03); stored value = this × 100 - const suggestRate = () => { - const ct = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); - if (!ct) return ''; - const ref = getTariffRef(selectedNationalityType, ct.code, selectedBedPosition || null); - return ref ? String(ref) : ''; - }; - - const columns = [ - { - key: 'nationalityType', label: 'Passenger Type', - render: (c: any) => ( - - {c.nationalityType === 'LOCAL' ? 'Local' : 'International'} - - ), - }, - { - key: 'coachType', label: 'Coach Type', - render: (c: any) => { - const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); - return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}; - }, - }, - { - key: 'name', label: 'Class Name', - render: (c: any) => {c.name}, - }, - { - key: 'baseFareMinor', label: 'Rate per km', - render: (c: any) => { - const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); - const ref = ct ? getTariffRef(c.nationalityType, ct.code, c.bedPosition) : undefined; - const tariffMinor = ref ? Math.round(ref * 100) : undefined; - const matches = tariffMinor === c.baseFareMinor; - return ( -
- {c.baseFareMinor / 100} - {tariffMinor !== undefined && ( - - {matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`} - - )} -
- ); - }, - }, - { - key: 'insuranceFeeMinor', label: 'Insurance Fee', - render: (c: any) => ( - {c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB - ), - }, - { - key: 'isActive', label: 'Status', - render: (c: any) => ( - - {c.isActive ? 'Active' : 'Inactive'} - - ), - }, + const tabs: { key: TabType; label: string }[] = [ + { key: 'tariff', label: 'Seat Class Tariffs' }, + { key: 'overrides', label: 'Route Overrides' }, + { key: 'baggage', label: 'Excess Luggage Rates' }, ]; - const actions = [ - { label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: openEdit }, - { - label: 'Delete', icon: Trash2, variant: 'danger' as const, - onClick: (c: any) => setDeleteConfirm({ isOpen: true, item: c }), - }, - ]; - - const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId) - ?? editingClass?.coachType; - const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed'); - return (
@@ -289,332 +68,72 @@ export default function TariffRatesPage() { Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy

- { + {tab !== 'overrides' && ( + { if (tab === 'baggage') { - setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' }); - setEditingAllowance(null); - setBaggageError(null); - setBaggageModal(true); + setShowBaggageModal(true); } else { setEditingClass(null); - setFormError(null); - setShowModal(true); + setPreselectedRouteId(null); + setShowRateModal(true); } - }} - > - {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'} - + }}> + {tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'} + + )}
- - + {tabs.map(t => ( + + ))}
{tab === 'tariff' && ( - <> -
- - setSearch(e.target.value)} - /> -
- - + { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }} + onDelete={handleDelete} + isDeleting={seatClassMutations.remove.isPending} + deleteError={deleteError} + deleteSuccess={deleteSuccess} + /> + )} + + {tab === 'overrides' && ( + )} {tab === 'baggage' && ( - allowancesLoading ? ( -
-
-
- ) : allowancesArray.length === 0 ? ( -
- No baggage allowance rules defined. Click "Add Allowance Rule" to create one. -
- ) : ( - {a.seatClass?.name ?? a.seatClassId} }, - { key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => {a.maxWeightKg} kg, {a.maxPiecesCount} pcs }, - { key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => {(a.excessFeePerKg / 100).toFixed(2)} ETB }, - ]} - actions={[ - { - label: 'Edit', icon: Edit, variant: 'secondary' as const, - onClick: (a: any) => { - setEditingAllowance(a); - setBaggageForm({ - seatClassId: a.seatClassId, - maxWeightKg: String(a.maxWeightKg), - maxPiecesCount: String(a.maxPiecesCount), - excessFeePerKg: (a.excessFeePerKg / 100).toFixed(2), - }); - setBaggageError(null); - setBaggageModal(true); - }, - }, - { - label: 'Delete', icon: Trash2, variant: 'danger' as const, - onClick: (a: any) => setDeleteAllowanceConfirm({ isOpen: true, id: a.id }), - }, - ]} - loading={false} - emptyMessage="No allowance rules found." - /> - ) + setShowBaggageModal(false)} + /> )}
- setDeleteConfirm({ isOpen: false, item: null })} - onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)} - title="Delete Tariff Rate" - message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`} - confirmText="Delete" - isDanger - isLoading={deleteMutation.isPending} - error={deleteConfirm.error} - warning="Bookings in progress may be affected. Ensure a replacement rate exists." + - - setDeleteAllowanceConfirm({ isOpen: false, id: null })} - onConfirm={() => deleteAllowanceMutation.mutateAsync(deleteAllowanceConfirm.id!)} - title="Delete Allowance Rule" - message="Are you sure you want to delete this baggage allowance rule?" - confirmText="Delete" - isDanger - isLoading={deleteAllowanceMutation.isPending} - warning="The excess baggage fallback rate (50 ETB/kg) will apply until a new rule is created." - /> - - { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }} - title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'} - size="md" - > -
- {baggageError && ( -
{baggageError}
- )} -
- - - {editingAllowance &&

Seat class cannot be changed. Delete and recreate to change.

} -
-
-
- - setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} /> -
-
- - setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} /> -
-
-
- - setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} /> -

Amount charged per kg above the free allowance

-
-
- { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel - - {editingAllowance ? 'Update' : 'Save'} - -
-
-
- - -
- {formError && ( -
- {formError} -
- )} - -
-
- - -
- -
- - -
- - {isBedCoach && ( -
- - -

- {selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'} -

-
- )} - -
- - - {!editingClass && suggestName() && ( -

- Suggested:{' '} - -

- )} -
- -
- - - {suggestRate() && ( -

- Official tariff rate:{' '} - - {' '}(stored as {Math.round(Number(suggestRate()) * 100)}) -

- )} -
- -
- - -

Flat fee per passenger (e.g., travel insurance)

-
- -
- - -
-
- -
- Cancel - - {editingClass ? 'Update' : 'Create'} Rate - -
-
-
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts new file mode 100644 index 000000000..b69dab6df --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/types.ts @@ -0,0 +1,47 @@ +export interface SeatClass { + id: string; + name: string; + coachTypeId?: string; + nationalityType?: string; + bedPosition?: string | null; + baseFareMinor?: number; + insuranceFeeMinor?: number; + isActive?: boolean; +} + +export interface CoachType { + id: string; + code: string; + name: string; +} + +export interface Route { + id: string; + name: string; + code?: string; +} + +export interface RouteFareRule { + id: string; + routeId: string; + seatClassId: string; + passengerCategory: string; + baseFareMinor: number; + surchargeMinor?: number | null; + validFrom: string; + validUntil?: string | null; + createdAt: string; + seatClass?: SeatClass; + route?: Route; +} + +export interface BaggageAllowance { + id: string; + seatClassId: string; + maxWeightKg: number; + maxPiecesCount: number; + excessFeePerKg: number; + seatClass?: SeatClass; +} + +export type TabType = 'tariff' | 'overrides' | 'baggage';