mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 21:45:03 +00:00
@@ -857,7 +857,7 @@ export class BookingsService {
|
|||||||
destinationStationId: dto.destinationStationId,
|
destinationStationId: dto.destinationStationId,
|
||||||
status: 'PENDING_PAYMENT',
|
status: 'PENDING_PAYMENT',
|
||||||
bookingType: 'ONE_WAY',
|
bookingType: 'ONE_WAY',
|
||||||
totalMinor: resolvedTotalMinor / 100,
|
totalMinor: resolvedTotalMinor,
|
||||||
adultCount,
|
adultCount,
|
||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
|
|||||||
@@ -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) {
|
if (segmentOverride) {
|
||||||
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||||
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
|
if (nationalityType === 'INTERNATIONAL' && !segmentOverride.nationality) {
|
||||||
@@ -110,6 +122,20 @@ export class FareEngineService {
|
|||||||
}
|
}
|
||||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
fareSource = 'SEGMENT_FARE_RULE';
|
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) {
|
} else if (fareRule?.tripId) {
|
||||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||||
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
|
if (nationalityType === 'INTERNATIONAL' && !fareRule.nationality) {
|
||||||
@@ -172,7 +198,7 @@ export class FareEngineService {
|
|||||||
const calculation = [
|
const calculation = [
|
||||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${nationalityType}${nationalityType === 'INTERNATIONAL' ? ' (2× surcharge applied)' : ''} → ${nationalitySeatClass.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)' : ''}`,
|
`Insurance: ${nationalitySeatClass.insuranceFeeMinor} minor → factor ${insuranceFactor}${insuranceAlreadyInBase ? ' (baked into base fare)' : ''}`,
|
||||||
`USD→ETB rate: ${usdToEtbRate}`,
|
`USD→ETB rate: ${usdToEtbRate}`,
|
||||||
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
|
`Base fare/pax: ${totalDistanceKm} km × (${nationalitySeatClass.baseFareMinor} / 100) × ${insuranceFactor} × ${usdToEtbRate} = ${baseFarePerPassengerMinor} ETB minor`,
|
||||||
|
|||||||
@@ -69,6 +69,40 @@ export class SchedulesController {
|
|||||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
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')
|
@Get('routes/:routeId/segment-fares')
|
||||||
@IsPublic()
|
@IsPublic()
|
||||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||||
|
|||||||
@@ -676,4 +676,63 @@ export class SchedulesService {
|
|||||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||||
return { message: 'Coach assignment removed' };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SeatClassesService } from './seat-classes.service';
|
import { SeatClassesService } from './seat-classes.service';
|
||||||
@@ -49,5 +49,7 @@ export class SeatClassesController {
|
|||||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Seat class deleted' })
|
@ApiResponse({ status: 200, description: 'Seat class deleted' })
|
||||||
@ApiResponse({ status: 404, description: 'Seat class not found' })
|
@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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class SeatClassesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSeatClass(id: string) {
|
async deleteSeatClass(id: string, cascade = false) {
|
||||||
const sc = await this.prisma.seatClass.findUnique({
|
const sc = await this.prisma.seatClass.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
@@ -59,11 +59,17 @@ export class SeatClassesService {
|
|||||||
(sc as any)._count.routeFareRules +
|
(sc as any)._count.routeFareRules +
|
||||||
(sc as any)._count.segmentFares;
|
(sc as any)._count.segmentFares;
|
||||||
|
|
||||||
if (totalFareRules > 0)
|
if (totalFareRules > 0 && !cascade)
|
||||||
throw new DeleteOperationException('Seat Class', sc.name, [
|
throw new DeleteOperationException('Seat Class', sc.name, [
|
||||||
{ entityName: 'fare rule', count: totalFareRules, action: 'delete' },
|
{ 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 } });
|
return this.prisma.seatClass.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -311,7 +311,6 @@ export default function ClassesPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="e.g., 25.00"
|
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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
106
apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
Normal file
106
apps/edr-passenger-web/backoffice/src/app/tariff-rates/hooks.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,285 +1,64 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { Plus } from 'lucide-react';
|
||||||
import { Plus, Edit, Trash2, Search } from 'lucide-react';
|
|
||||||
import DataTable from '@/components/ui/DataTable';
|
|
||||||
import Badge from '@/components/ui/Badge';
|
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import TariffTab from './TariffTab';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import OverridesTab from './OverridesTab';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import BaggageTab from './BaggageTab';
|
||||||
|
import RateModal from './RateModal';
|
||||||
interface SeatClass { id: string; name: string; }
|
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
|
||||||
|
import type { SeatClass, TabType } from './types';
|
||||||
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];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TariffRatesPage() {
|
export default function TariffRatesPage() {
|
||||||
const [tab, setTab] = useState<'tariff' | 'baggage'>('tariff');
|
const [tab, setTab] = useState<TabType>('tariff');
|
||||||
const [search, setSearch] = useState('');
|
const [showRateModal, setShowRateModal] = useState(false);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
|
||||||
const [editingClass, setEditingClass] = useState<any>(null);
|
const [showBaggageModal, setShowBaggageModal] = useState(false);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
const [preselectedRouteId, setPreselectedRouteId] = useState<string | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
|
||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState('');
|
const [deleteSuccess, setDeleteSuccess] = useState(0);
|
||||||
const [selectedBedPosition, setSelectedBedPosition] = useState<string>('');
|
|
||||||
const [selectedNationalityType, setSelectedNationalityType] = useState<string>('LOCAL');
|
|
||||||
|
|
||||||
const [baggageForm, setBaggageForm] = useState({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
|
const { allClasses, isLoading } = useSeatClasses();
|
||||||
const [editingAllowance, setEditingAllowance] = useState<any>(null);
|
const { coachTypes } = useCoachTypes();
|
||||||
const [baggageError, setBaggageError] = useState<string | null>(null);
|
const { routes } = useRoutes();
|
||||||
const [baggageModal, setBaggageModal] = useState(false);
|
|
||||||
const [deleteAllowanceConfirm, setDeleteAllowanceConfirm] = useState<{ isOpen: boolean; id: string | null }>({ isOpen: false, id: null });
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const closeRateModal = () => { setShowRateModal(false); setEditingClass(null); setPreselectedRouteId(null); };
|
||||||
|
|
||||||
const { data: allowances, isLoading: allowancesLoading, refetch: refetchAllowances } = useQuery({
|
const seatClassMutations = useSeatClassMutations(closeRateModal);
|
||||||
queryKey: ['baggage-allowances'],
|
const overrideMutations = useRouteFareRuleMutations(preselectedRouteId);
|
||||||
queryFn: () => apiClient.get<any[]>('/agents/excess-baggage/allowances'),
|
|
||||||
enabled: tab === 'baggage',
|
|
||||||
});
|
|
||||||
|
|
||||||
const createAllowanceMutation = useMutation({
|
const handleSubmitGlobal = async (payload: any) => {
|
||||||
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',
|
|
||||||
};
|
|
||||||
if (editingClass) {
|
if (editingClass) {
|
||||||
await updateMutation.mutateAsync({ id: editingClass.id, data: payload });
|
await seatClassMutations.update.mutateAsync({ id: editingClass.id, data: payload });
|
||||||
} else {
|
} 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)
|
const tabs: { key: TabType; label: string }[] = [
|
||||||
? coachTypesData
|
{ key: 'tariff', label: 'Seat Class Tariffs' },
|
||||||
: (coachTypesData as any)?.data || (coachTypesData as any)?.items || [];
|
{ key: 'overrides', label: 'Route Overrides' },
|
||||||
|
{ key: 'baggage', label: 'Excess Luggage Rates' },
|
||||||
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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<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
|
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
{tab !== 'overrides' && (
|
||||||
icon={Plus}
|
<ActionButton icon={Plus} onClick={() => {
|
||||||
onClick={() => {
|
|
||||||
if (tab === 'baggage') {
|
if (tab === 'baggage') {
|
||||||
setBaggageForm({ seatClassId: '', maxWeightKg: '', maxPiecesCount: '', excessFeePerKg: '' });
|
setShowBaggageModal(true);
|
||||||
setEditingAllowance(null);
|
|
||||||
setBaggageError(null);
|
|
||||||
setBaggageModal(true);
|
|
||||||
} else {
|
} else {
|
||||||
setEditingClass(null);
|
setEditingClass(null);
|
||||||
setFormError(null);
|
setPreselectedRouteId(null);
|
||||||
setShowModal(true);
|
setShowRateModal(true);
|
||||||
}
|
}
|
||||||
}}
|
}}>
|
||||||
>
|
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
|
||||||
{tab === 'baggage' ? 'Add Allowance Rule' : 'Add Rate'}
|
</ActionButton>
|
||||||
</ActionButton>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex gap-4 border-b mb-6">
|
<div className="flex gap-4 border-b mb-6">
|
||||||
<button
|
{tabs.map(t => (
|
||||||
onClick={() => setTab('tariff')}
|
<button
|
||||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'tariff' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
|
key={t.key}
|
||||||
>
|
onClick={() => setTab(t.key)}
|
||||||
Seat Class Tariffs
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'}`}
|
||||||
</button>
|
>
|
||||||
<button
|
{t.label}
|
||||||
onClick={() => setTab('baggage')}
|
</button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'tariff' && (
|
{tab === 'tariff' && (
|
||||||
<>
|
<TariffTab
|
||||||
<div className="relative mb-4">
|
classes={allClasses}
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
coachTypes={coachTypes}
|
||||||
<input
|
isLoading={isLoading}
|
||||||
type="text"
|
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
|
||||||
placeholder="Search by name, nationality, etc."
|
onDelete={handleDelete}
|
||||||
className="input pl-10 w-full"
|
isDeleting={seatClassMutations.remove.isPending}
|
||||||
value={search}
|
deleteError={deleteError}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
deleteSuccess={deleteSuccess}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
<DataTable
|
|
||||||
data={displayed}
|
{tab === 'overrides' && (
|
||||||
columns={columns}
|
<OverridesTab routes={routes} />
|
||||||
actions={actions}
|
|
||||||
loading={isLoading}
|
|
||||||
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'baggage' && (
|
{tab === 'baggage' && (
|
||||||
allowancesLoading ? (
|
<BaggageTab
|
||||||
<div className="flex items-center justify-center py-8">
|
allClasses={allClasses}
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
isOpen={showBaggageModal}
|
||||||
</div>
|
onClose={() => setShowBaggageModal(false)}
|
||||||
) : 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."
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<RateModal
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={showRateModal}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
onClose={closeRateModal}
|
||||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.item?.id)}
|
editingClass={editingClass}
|
||||||
title="Delete Tariff Rate"
|
allClasses={allClasses}
|
||||||
message={`Delete "${deleteConfirm.item?.name}"? This will affect fare calculations for this class.`}
|
coachTypes={coachTypes}
|
||||||
confirmText="Delete"
|
routes={routes}
|
||||||
isDanger
|
preselectedRouteId={preselectedRouteId}
|
||||||
isLoading={deleteMutation.isPending}
|
onSubmitGlobal={handleSubmitGlobal}
|
||||||
error={deleteConfirm.error}
|
onSubmitOverride={handleSubmitOverride}
|
||||||
warning="Bookings in progress may be affected. Ensure a replacement rate exists."
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user