This commit is contained in:
Roba Boru
2026-07-14 16:08:52 +03:00
15 changed files with 1237 additions and 580 deletions

View File

@@ -857,7 +857,7 @@ export class BookingsService {
destinationStationId: dto.destinationStationId,
status: 'PENDING_PAYMENT',
bookingType: 'ONE_WAY',
totalMinor: resolvedTotalMinor / 100,
totalMinor: resolvedTotalMinor,
adultCount,
childCount,
displayCurrency,

View File

@@ -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`,

View File

@@ -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' })

View File

@@ -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 };
}
}

View File

@@ -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');
}
}

View File

@@ -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 } });
}
}

View File

@@ -311,7 +311,6 @@ export default function ClassesPage() {
step="0.01"
placeholder="e.g., 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
</div>

View File

@@ -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<BaggageAllowance | null>(null);
const [error, setError] = useState<string | null>(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 ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : (
<DataTable
data={allowances}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: BaggageAllowance) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: BaggageAllowance) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: BaggageAllowance) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
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.'
/>
)}
<Modal
isOpen={isOpen || !!editing}
onClose={() => { resetForm(); onClose(); }}
title={editing ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
size="md"
>
<div className="space-y-4">
{error && <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>}
<div>
<label className="label">Seat Class *</label>
<select value={form.seatClassId} onChange={e => setForm({ ...form, seatClassId: e.target.value })} className="input w-full" disabled={!!editing}>
<option value="">Select seat class...</option>
{allClasses.map(sc => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editing && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={form.maxWeightKg} onChange={e => setForm({ ...form, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={form.maxPiecesCount} onChange={e => setForm({ ...form, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={form.excessFeePerKg} onChange={e => setForm({ ...form, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { resetForm(); onClose(); }}>Cancel</ActionButton>
<ActionButton onClick={handleSave} loading={create.isPending || update.isPending}>
{editing ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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."
/>
</>
);
}

View File

@@ -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<string | null>(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<OverrideForm>({ 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<HTMLFormElement>) => {
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) => <span className="font-medium">{r.route?.name ?? r.routeId}</span>,
},
{
key: 'seatClass', label: 'Seat Class',
render: (r: RouteFareRule) => <span>{r.seatClass?.name ?? r.seatClassId}</span>,
},
{
key: 'passengerCategory', label: 'Category',
render: (r: RouteFareRule) => (
<Badge variant="status" status={r.passengerCategory === 'ADULT' ? 'CONFIRMED' : 'INFO'}>
{r.passengerCategory}
</Badge>
),
},
{
key: 'baseFareMinor', label: 'Rate per km',
render: (r: RouteFareRule) => <span className="font-mono font-medium">{r.baseFareMinor / 100}</span>,
},
{
key: 'surchargeMinor', label: 'Insurance Fee',
render: (r: RouteFareRule) => (
<span className="font-mono text-sm">
{r.surchargeMinor ? (r.surchargeMinor / 100).toFixed(2) : '0.00'} ETB
</span>
),
},
{
key: 'validFrom', label: 'Valid From',
render: (r: RouteFareRule) => <span className="text-sm">{new Date(r.validFrom).toLocaleDateString()}</span>,
},
{
key: 'validUntil', label: 'Valid Until',
render: (r: RouteFareRule) => (
<span className="text-sm">{r.validUntil ? new Date(r.validUntil).toLocaleDateString() : '—'}</span>
),
},
];
const isAddMode = form.isOpen && !form.rule;
const isPending = create.isPending || update.isPending;
return (
<>
<div className="flex items-center gap-3 mb-4">
<select
className="input w-64"
value={selectedRouteId ?? ''}
onChange={e => setSelectedRouteId(e.target.value || null)}
>
<option value="">All routes</option>
{routes.map(r => (
<option key={r.id} value={r.id}>{r.name}{r.code ? ` (${r.code})` : ''}</option>
))}
</select>
<ActionButton
icon={Plus}
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
>
Add Override
</ActionButton>
</div>
{!selectedRouteId ? (
<div className="text-center py-8 text-muted-foreground">
Select a route above to view its fare overrides.
</div>
) : (
<DataTable
data={overrides}
columns={columns}
actions={[
{
label: 'Edit', icon: Edit, variant: 'secondary' as const,
onClick: (r: RouteFareRule) => 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 */}
<Modal
isOpen={form.isOpen}
onClose={() => setForm({ isOpen: false, rule: null, error: null })}
title={isAddMode ? 'Add Route Override' : 'Edit Route Override'}
size="md"
>
<form key={form.rule?.id ?? 'add'} onSubmit={handleFormSubmit} className="space-y-4">
{form.error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{form.error}
</div>
)}
{isAddMode ? (
<div className="grid grid-cols-1 gap-4">
<div>
<label className="label">Route *</label>
<select name="routeId" className="input w-full" defaultValue={selectedRouteId ?? ''} required>
<option value="">Select route</option>
{routes.map(r => (
<option key={r.id} value={r.id}>{r.name}{r.code ? ` (${r.code})` : ''}</option>
))}
</select>
</div>
<div>
<label className="label">Seat Class Tariff *</label>
<select name="seatClassId" className="input w-full" required>
<option value="">Select seat class</option>
{allClasses.filter(c => c.isActive).map((c: SeatClass) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
</div>
) : (
<div className="p-3 rounded-lg bg-muted/50 text-sm text-muted-foreground">
<span className="font-medium text-foreground">{form.rule?.seatClass?.name ?? form.rule?.seatClassId}</span>
{' · '}{form.rule?.route?.name ?? form.rule?.routeId}
{' · '}{form.rule?.passengerCategory}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Rate per km *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={form.rule ? form.rule.baseFareMinor / 100 : ''}
min="0" step="any" required
/>
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="surchargeMinor"
className="input"
defaultValue={form.rule ? ((form.rule.surchargeMinor ?? 0) / 100).toFixed(2) : '0.00'}
min="0" step="0.01"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton type="button" variant="secondary" onClick={() => setForm({ isOpen: false, rule: null, error: null })}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{isAddMode ? 'Create Override' : 'Update Override'}
</ActionButton>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 }))}
/>
</>
);
}

View File

@@ -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<void>;
onSubmitOverride: (routeId: string, payload: any) => Promise<void>;
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<string | null>(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<HTMLFormElement>) => {
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 (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="lg">
<form key={editingClass?.id ?? `new-${preselectedRouteId}`} onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{error}
</div>
)}
{!editingClass && allowRouteOverride && (
<div>
<label className="label">Route Override (optional)</label>
<select className="input w-full" value={routeId} onChange={e => setRouteId(e.target.value)}>
<option value="">Global rate (applies to all routes)</option>
{(routes ?? []).map(r => (
<option key={r.id} value={r.id}>{r.name}{r.code ? ` (${r.code})` : ''}</option>
))}
</select>
{isOverrideMode && (
<p className="text-xs text-muted-foreground mt-1">
Override applies only to this route. A matching global seat class must already exist.
</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Nationality *</label>
<select className="input" value={nationalityType} onChange={e => setNationalityType(e.target.value)} required>
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
<option value="INTERNATIONAL">International (Foreign nationals)</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={coachTypeId}
onChange={e => { setCoachTypeId(e.target.value); setBedPosition(''); }}
required
>
<option value="">Select coach type</option>
{coachTypes.map(ct => (
<option key={ct.id} value={ct.id}>{ct.code} {ct.name}</option>
))}
</select>
</div>
{isBedCoach && (
<div>
<label className="label">Bed Position *</label>
<select className="input" value={bedPosition} onChange={e => setBedPosition(e.target.value)} required>
<option value="">Select bed position</option>
{BED_POSITIONS.map(pos => <option key={pos} value={pos}>{pos}</option>)}
</select>
<p className="text-xs text-muted-foreground mt-1">
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
</p>
</div>
)}
{!isOverrideMode && (
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name ?? ''}
key={editingClass?.id ?? `name-${coachTypeId}-${bedPosition}-${nationalityType}`}
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
required
/>
{!editingClass && suggestName() && (
<p className="text-xs text-muted-foreground mt-1">
Suggested:{' '}
<button type="button" className="text-primary underline" onClick={e => {
const inp = (e.currentTarget.closest('form')?.querySelector('input[name=name]') as HTMLInputElement);
if (inp) inp.value = suggestName();
}}>{suggestName()}</button>
</p>
)}
</div>
)}
<div>
<label className="label">Rate per km *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass ? editingClass.baseFareMinor! / 100 : ''}
key={editingClass?.id ?? `rate-${coachTypeId}-${bedPosition}-${nationalityType}`}
placeholder={suggestRate() || 'e.g. 0.06'}
min="0"
step="any"
required
/>
{suggestRate() && (
<p className="text-xs text-muted-foreground mt-1">
Official tariff:{' '}
<button type="button" className="text-primary underline" onClick={e => {
const inp = (e.currentTarget.closest('form')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
if (inp) inp.value = suggestRate();
}}>{suggestRate()}</button>
{' '}(stored as {Math.round(Number(suggestRate()) * 100)})
</p>
)}
</div>
{/* Insurance fee — different field name depending on mode */}
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name={isOverrideMode ? 'surchargeMinor' : 'insuranceFeeMinor'}
className="input"
defaultValue={editingClass ? (editingClass.insuranceFeeMinor! / 100).toFixed(2) : '0.00'}
key={editingClass?.id ?? `ins-${isOverrideMode}`}
min="0"
step="0.01"
placeholder="e.g. 25.00"
/>
</div>
{!isOverrideMode && (
<div>
<label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={onClose}>Cancel</ActionButton>
<ActionButton type="submit" loading={isPending}>
{editingClass ? 'Update Rate' : isOverrideMode ? 'Create Override' : 'Create Rate'}
</ActionButton>
</div>
</form>
</Modal>
);
}

View File

@@ -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) => (
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
</Badge>
),
},
{
key: 'coachType', label: 'Coach Type',
render: (c: SeatClass) => {
const ct = coachTypes.find(t => t.id === c.coachTypeId);
return <span className="text-sm">{ct ? `${ct.code}${ct.name}` : c.coachTypeId}</span>;
},
},
{
key: 'name', label: 'Class Name',
render: (c: SeatClass) => <span className="font-medium">{c.name}</span>,
},
{
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 (
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{c.baseFareMinor! / 100}</span>
{tariffMinor !== undefined && (
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
</span>
)}
</div>
);
},
},
{
key: 'insuranceFeeMinor', label: 'Insurance Fee',
render: (c: SeatClass) => (
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'isActive', label: 'Status',
render: (c: SeatClass) => (
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{c.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
];
return (
<>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, etc."
className="input pl-10 w-full"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<DataTable
data={displayed}
columns={columns}
actions={[
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit },
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
]}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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 }))}
/>
</>
);
}

View File

@@ -0,0 +1,31 @@
export const BED_POSITIONS = ['UPPER', 'MIDDLE', 'LOWER'] as const;
export const COACH_TYPE_LABELS: Record<string, string> = {
HSC: 'Regular Seat (Hard Seat)',
HBC: 'Economy Bed (Hard Berth)',
SBC: 'VIP Bed (Soft Berth)',
};
export const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
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];
}

View File

@@ -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<T>(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<unknown>('/seat-classes'),
});
return { allClasses: toArray<SeatClass>(data), isLoading };
}
export function useCoachTypes() {
const { data } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get<unknown>('/fleet/coach-types'),
});
return { coachTypes: toArray<CoachType>(data) };
}
export function useRoutes() {
const { data } = useQuery({
queryKey: ['routes-active'],
queryFn: () => apiClient.get<unknown>('/routes?activeOnly=true'),
});
return { routes: toArray<Route>(data) };
}
export function useRouteFareRules(routeId: string | null) {
const { data, isLoading, refetch } = useQuery({
queryKey: ['route-fare-rules', routeId],
queryFn: () => apiClient.get<unknown>(`/schedules/routes/${routeId}/fare-rules`),
enabled: !!routeId,
});
return { overrides: toArray<RouteFareRule>(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<any>(`/schedules/routes/fare-rules/${id}`, data),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete<any>(`/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<unknown>('/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<BaggageAllowance>(data), isLoading, create, update, remove };
}

View File

@@ -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<string, string> = {
HSC: 'Regular Seat (Hard Seat)',
HBC: 'Economy Bed (Hard Berth)',
SBC: 'VIP Bed (Soft Berth)',
};
const TARIFF_REFERENCE: Record<string, Record<string, number>> = {
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<any>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
const [formError, setFormError] = useState<string | null>(null);
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
const [tab, setTab] = useState<TabType>('tariff');
const [showRateModal, setShowRateModal] = useState(false);
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
const [showBaggageModal, setShowBaggageModal] = useState(false);
const [preselectedRouteId, setPreselectedRouteId] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
const [deleteSuccess, setDeleteSuccess] = useState(0);
const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
const [editingAllowance, setEditingAllowance] = useState<any>(null);
const [baggageError, setBaggageError] = useState<string | null>(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<any[]>('/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<any>('/seat-classes'),
});
const { data: coachTypesData } = useQuery({
queryKey: ['coach-types'],
queryFn: () => apiClient.get<any>('/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<HTMLFormElement>) => {
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) => (
<Badge variant="status" status={c.nationalityType === 'LOCAL' ? 'CONFIRMED' : 'INFO'}>
{c.nationalityType === 'LOCAL' ? 'Local' : 'International'}
</Badge>
),
},
{
key: 'coachType', label: 'Coach Type',
render: (c: any) => {
const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId);
return <span className="text-sm">{ct ? `${ct.code}${ct.name}` : c.coachTypeId}</span>;
},
},
{
key: 'name', label: 'Class Name',
render: (c: any) => <span className="font-medium">{c.name}</span>,
},
{
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 (
<div className="flex items-center gap-2">
<span className="font-mono font-medium">{c.baseFareMinor / 100}</span>
{tariffMinor !== undefined && (
<span className={`text-xs px-1.5 py-0.5 rounded ${matches ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'}`}>
{matches ? '✓ tariff' : `tariff: ${tariffMinor / 100}`}
</span>
)}
</div>
);
},
},
{
key: 'insuranceFeeMinor', label: 'Insurance Fee',
render: (c: any) => (
<span className="font-mono text-sm">{c.insuranceFeeMinor ? (c.insuranceFeeMinor / 100).toFixed(2) : '0.00'} ETB</span>
),
},
{
key: 'isActive', label: 'Status',
render: (c: any) => (
<Badge variant="status" status={c.isActive ? 'CONFIRMED' : 'CANCELLED'}>
{c.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -289,332 +68,72 @@ export default function TariffRatesPage() {
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
</p>
</div>
<ActionButton
icon={Plus}
onClick={() => {
{tab !== 'overrides' && (
<ActionButton icon={Plus} onClick={() => {
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'}
</ActionButton>
}}>
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
</ActionButton>
)}
</div>
<div className="card">
<div className="flex gap-4 border-b mb-6">
<button
onClick={() => 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
</button>
<button
onClick={() => 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
</button>
{tabs.map(t => (
<button
key={t.key}
onClick={() => 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}
</button>
))}
</div>
{tab === 'tariff' && (
<>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search by name, nationality, etc."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<DataTable
data={displayed}
columns={columns}
actions={actions}
loading={isLoading}
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
/>
</>
<TariffTab
classes={allClasses}
coachTypes={coachTypes}
isLoading={isLoading}
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
onDelete={handleDelete}
isDeleting={seatClassMutations.remove.isPending}
deleteError={deleteError}
deleteSuccess={deleteSuccess}
/>
)}
{tab === 'overrides' && (
<OverridesTab routes={routes} />
)}
{tab === 'baggage' && (
allowancesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : allowancesArray.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No baggage allowance rules defined. Click "Add Allowance Rule" to create one.
</div>
) : (
<DataTable
data={allowancesArray}
columns={[
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
]}
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."
/>
)
<BaggageTab
allClasses={allClasses}
isOpen={showBaggageModal}
onClose={() => setShowBaggageModal(false)}
/>
)}
</div>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => 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."
<RateModal
isOpen={showRateModal}
onClose={closeRateModal}
editingClass={editingClass}
allClasses={allClasses}
coachTypes={coachTypes}
routes={routes}
preselectedRouteId={preselectedRouteId}
onSubmitGlobal={handleSubmitGlobal}
onSubmitOverride={handleSubmitOverride}
isPending={seatClassMutations.create.isPending || seatClassMutations.update.isPending || overrideMutations.create.isPending}
/>
<ConfirmDialog
isOpen={deleteAllowanceConfirm.isOpen}
onClose={() => 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."
/>
<Modal
isOpen={baggageModal}
onClose={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}
title={editingAllowance ? 'Edit Allowance Rule' : 'Add Allowance Rule'}
size="md"
>
<div className="space-y-4">
{baggageError && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{baggageError}</div>
)}
<div>
<label className="label">Seat Class *</label>
<select value={baggageForm.seatClassId} onChange={(e) => setBaggageForm({ ...baggageForm, seatClassId: e.target.value })} className="input w-full" disabled={!!editingAllowance}>
<option value="">Select seat class...</option>
{allClasses.map((sc: SeatClass) => <option key={sc.id} value={sc.id}>{sc.name}</option>)}
</select>
{editingAllowance && <p className="text-xs text-muted-foreground mt-1">Seat class cannot be changed. Delete and recreate to change.</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Free Allowance (kg) *</label>
<input type="number" min="0" className="input w-full" placeholder="e.g. 20" value={baggageForm.maxWeightKg} onChange={(e) => setBaggageForm({ ...baggageForm, maxWeightKg: e.target.value })} />
</div>
<div>
<label className="label">Max Pieces *</label>
<input type="number" min="1" className="input w-full" placeholder="e.g. 2" value={baggageForm.maxPiecesCount} onChange={(e) => setBaggageForm({ ...baggageForm, maxPiecesCount: e.target.value })} />
</div>
</div>
<div>
<label className="label">Excess Fee per kg (ETB) *</label>
<input type="number" min="0" step="0.01" className="input w-full" placeholder="e.g. 50.00" value={baggageForm.excessFeePerKg} onChange={(e) => setBaggageForm({ ...baggageForm, excessFeePerKg: e.target.value })} />
<p className="text-xs text-muted-foreground mt-1">Amount charged per kg above the free allowance</p>
</div>
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => { setBaggageModal(false); setEditingAllowance(null); setBaggageError(null); }}>Cancel</ActionButton>
<ActionButton onClick={handleSaveAllowance} loading={createAllowanceMutation.isPending || updateAllowanceMutation.isPending}>
{editingAllowance ? 'Update' : 'Save'}
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showModal}
onClose={closeModal}
title={`${editingClass ? 'Edit' : 'Add'} Tariff Rate`}
size="lg"
>
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-3 text-sm text-red-800 dark:text-red-200">
{formError}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="label">Passenger Nationality *</label>
<select
className="input"
value={selectedNationalityType}
onChange={(e) => setSelectedNationalityType(e.target.value)}
required
>
<option value="LOCAL">Local (Ethiopian / Djiboutian)</option>
<option value="INTERNATIONAL">International (Foreign nationals)</option>
</select>
</div>
<div>
<label className="label">Coach Type *</label>
<select
className="input"
value={selectedCoachTypeId}
onChange={(e) => { setSelectedCoachTypeId(e.target.value); setSelectedBedPosition(''); }}
required
>
<option value="">Select coach type</option>
{coachTypesArray.map((ct: any) => (
<option key={ct.id} value={ct.id}>
{ct.code} {ct.name}
</option>
))}
</select>
</div>
{isBedCoach && (
<div>
<label className="label">Bed Position *</label>
<select
className="input"
value={selectedBedPosition}
onChange={(e) => setSelectedBedPosition(e.target.value)}
required={isBedCoach}
>
<option value="">Select bed position</option>
{(selectedCoachType?.code === 'HBC'
? BED_POSITIONS
: (['Upper','Middle', 'Lower'] as const)
).map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{selectedCoachType?.code === 'HBC' ? 'Economy Bed: Upper / Middle / Lower' : 'VIP Bed: Upper / Lower'}
</p>
</div>
)}
<div>
<label className="label">Class Name *</label>
<input
type="text"
name="name"
className="input"
defaultValue={editingClass?.name || ''}
key={editingClass?.id ?? `new-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestName() || 'e.g. Economy Bed Upper (Local)'}
required
/>
{!editingClass && suggestName() && (
<p className="text-xs text-muted-foreground mt-1">
Suggested:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=name]') as HTMLInputElement);
if (inp) inp.value = suggestName();
}}
>
{suggestName()}
</button>
</p>
)}
</div>
<div>
<label className="label">Rate per km *</label>
<input
type="number"
name="baseFareMinor"
className="input"
defaultValue={editingClass ? editingClass.baseFareMinor / 100 : ''}
key={editingClass?.id ?? `rate-${selectedCoachTypeId}-${selectedBedPosition}-${selectedNationalityType}`}
placeholder={suggestRate() || 'e.g. 6'}
min="0"
step="any"
required
/>
{suggestRate() && (
<p className="text-xs text-muted-foreground mt-1">
Official tariff rate:{' '}
<button
type="button"
className="text-primary underline"
onClick={(e) => {
const inp = (e.currentTarget.closest('.space-y-4')?.querySelector('input[name=baseFareMinor]') as HTMLInputElement);
if (inp) inp.value = suggestRate();
}}
>
{suggestRate()}
</button>
{' '}(stored as {Math.round(Number(suggestRate()) * 100)})
</p>
)}
</div>
<div>
<label className="label">Insurance Fee (ETB)</label>
<input
type="number"
name="insuranceFeeMinor"
className="input"
defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'}
key={editingClass?.id ?? 'new-insurance'}
min="0"
step="0.01"
placeholder="e.g. 25.00"
/>
<p className="text-xs text-muted-foreground mt-1">Flat fee per passenger (e.g., travel insurance)</p>
</div>
<div>
<label className="label">Status</label>
<select name="isActive" className="input" defaultValue={editingClass?.isActive !== false ? 'true' : 'false'}>
<option value="true">Active</option>
<option value="false">Inactive</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<ActionButton type="button" variant="secondary" onClick={closeModal}>Cancel</ActionButton>
<ActionButton type="submit" loading={createMutation.isPending || updateMutation.isPending}>
{editingClass ? 'Update' : 'Create'} Rate
</ActionButton>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -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';