mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Price issue on sign in path addressed, Djibouti side pricing updates added
This commit is contained in:
@@ -857,7 +857,7 @@ export class BookingsService {
|
||||
destinationStationId: dto.destinationStationId,
|
||||
status: 'PENDING_PAYMENT',
|
||||
bookingType: 'ONE_WAY',
|
||||
totalMinor: resolvedTotalMinor / 100,
|
||||
totalMinor: resolvedTotalMinor,
|
||||
adultCount,
|
||||
childCount,
|
||||
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) {
|
||||
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`,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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