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}
}
+
+
Seat Class *
+
setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
+ Select seat class...
+ {allClasses.map(sc => {sc.name} )}
+
+ {editing &&
Seat class cannot be changed. Delete and recreate to change.
}
+
+
+
+
Excess Fee per kg (ETB) *
+
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 (
+ <>
+
+
setSelectedRouteId(e.target.value || null)}
+ >
+ All routes
+ {routes.map(r => (
+ {r.name}{r.code ? ` (${r.code})` : ''}
+ ))}
+
+
+
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"
+ >
+
+
+
+ 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 (
+
+
+
+ );
+}
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'}
+
+ )}
- setTab('tariff')}
- className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'tariff' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
- >
- Seat Class Tariffs
-
- setTab('baggage')}
- className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
- >
- Excess Luggage Rates
-
+ {tabs.map(t => (
+ setTab(t.key)}
+ className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
+ >
+ {t.label}
+
+ ))}
{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}
- )}
-
-
Seat Class *
-
setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
- Select seat class...
- {allClasses.map((sc: SeatClass) => {sc.name} )}
-
- {editingAllowance &&
Seat class cannot be changed. Delete and recreate to change.
}
-
-
-
-
Excess Fee per kg (ETB) *
-
setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
-
Amount charged per kg above the free allowance
-
-
-
{ setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel
-
- {editingAllowance ? 'Update' : 'Save'}
-
-
-
-
-
-
-
-
);
}
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';