Luggage processing,, schedule times and tariff related updates

This commit is contained in:
Stephanos A
2026-07-09 11:00:56 +03:00
parent 9825d37e66
commit 554756a116
15 changed files with 365 additions and 196 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
@@ -34,6 +34,12 @@ export class AgentsController {
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) {
return this.service.deleteAgent(id);
}
@Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -210,4 +210,11 @@ export class AgentsService {
},
});
}
async deleteAgent(id: string) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
await this.prisma.agent.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
@@ -26,7 +26,8 @@ export class ExcessBaggageAgentController {
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Body() dto: LogExcessBaggageDto) {
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
return this.service.logCharge(dto);
}
@@ -50,24 +51,6 @@ export class ExcessBaggageAgentController {
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Get('allowances')
@ApiOperation({ summary: 'List all baggage allowance rules' })
getAllowances() {
@@ -92,6 +75,24 @@ export class ExcessBaggageAgentController {
return this.service.deleteAllowance(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) {

View File

@@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LogExcessBaggageDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
@IsOptional() @IsString() agentId?: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })

View File

@@ -75,7 +75,7 @@ export class ExcessBaggageService {
const charge = await this.prisma.excessBaggageCharge.create({
data: {
bookingId: dto.bookingId,
agentId: dto.agentId,
agentId: dto.agentId ?? '',
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,

View File

@@ -154,43 +154,54 @@ export class SearchService {
) {
const [y, m, d] = dateStr.split('-').map(Number);
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
const now = new Date();
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
const daysAfter = 14 - daysBefore;
const windowStart = new Date(requestedDate);
windowStart.setDate(windowStart.getDate() - daysBefore);
if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
const totalPassengers = adultCount + (childCount ?? 0);
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
});
const baseWhere = {
status: 'SCHEDULED',
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
const FETCH_LIMIT = NEEDED * 5;
const [beforeCandidates, afterCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'desc' },
take: FETCH_LIMIT,
}),
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
take: FETCH_LIMIT,
}),
]);
const pickN = async (candidates: typeof beforeCandidates, limit: number) => {
const out: NonNullable<Awaited<ReturnType<typeof this.buildScheduleResult>>>[] = [];
for (const schedule of candidates) {
if (out.length >= limit) break;
const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality);
if (r?.hasAvailability) out.push(r);
}
return out;
};
const [before, after] = await Promise.all([
pickN(beforeCandidates, NEEDED),
pickN(afterCandidates, NEEDED),
]);
// before was fetched desc (closest first); reverse so result is chronological
return [...before.reverse(), ...after];
}
private async searchSchedules(
@@ -415,7 +426,7 @@ export class SearchService {
}
}
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -638,6 +649,10 @@ export class SearchService {
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const nationalityUpper = (nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
@@ -647,7 +662,14 @@ export class SearchService {
}
}
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
where: {
id: { in: Array.from(seatClassIdSet) },
isActive: true,
OR: [
{ nationalityType: null },
{ nationalityType: nationalityType },
],
},
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
@@ -725,6 +747,7 @@ export class SearchService {
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
nationality?: string,
): Array<{
coachTypeId: string;
coachTypeName: string;
@@ -749,8 +772,16 @@ export class SearchService {
});
}
const nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
coachType.seatClasses?.forEach((sc: any) => {
// Exclude classes that belong to the wrong nationality type
if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return;
if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name);
});
}
const result = [];
@@ -769,6 +800,7 @@ export class SearchService {
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,

View File

@@ -2,11 +2,12 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Edit, Eye } from 'lucide-react';
import { Plus, Edit, Eye, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { agentsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
@@ -48,6 +49,19 @@ export default function AgentsPage() {
const [editingAgent, setEditingAgent] = useState<any>(null);
const [editError, setEditError] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null });
const [deleteError, setDeleteError] = useState<string | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => agentsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agents'] });
setDeleteConfirm({ isOpen: false, agent: null });
setDeleteError(null);
},
onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'),
});
const editMutation = useMutation({
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data),
onSuccess: () => {
@@ -122,6 +136,12 @@ export default function AgentsPage() {
variant: 'secondary' as const,
icon: Eye,
},
{
label: 'Delete',
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
variant: 'danger' as const,
icon: Trash2,
},
];
return (
@@ -169,6 +189,18 @@ export default function AgentsPage() {
emptyMessage="No agents found"
/>
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
onClose={() => { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }}
onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }}
title="Delete Agent"
message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
error={deleteError ?? undefined}
/>
{/* Agent Details Modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Agent Details" size="xl">
{selected && (() => {

View File

@@ -2,13 +2,14 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, Send, Trash2 } from 'lucide-react';
import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import { excessBaggageApi } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useAuthStore } from '@/lib/auth-store';
const STATUS_VARIANT: Record<string, any> = {
PENDING: 'PENDING',
@@ -22,9 +23,16 @@ export default function ExcessBaggagePage() {
const queryClient = useQueryClient();
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
const [showExtraFilters, setShowExtraFilters] = useState(false);
const user = useAuthStore((s) => s.user);
const [waiveModal, setWaiveModal] = useState<any>(null);
const [waiveReason, setWaiveReason] = useState('');
const [waiveError, setWaiveError] = useState<string | null>(null);
const [logModal, setLogModal] = useState(false);
const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false });
const [logError, setLogError] = useState<string | null>(null);
const [resendModal, setResendModal] = useState<any>(null);
const [resendSuccess, setResendSuccess] = useState(false);
const [resendError, setResendError] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['excess-baggage', filters],
@@ -37,6 +45,17 @@ export default function ExcessBaggagePage() {
}),
});
const logMutation = useMutation({
mutationFn: (data: any) => excessBaggageApi.logCharge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setLogModal(false);
setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false });
setLogError(null);
},
onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'),
});
const waiveMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }),
@@ -51,7 +70,12 @@ export default function ExcessBaggagePage() {
const resendMutation = useMutation({
mutationFn: (id: string) => excessBaggageApi.resendLink(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['excess-baggage'] });
setResendSuccess(true);
setResendError(null);
},
onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'),
});
const deleteMutation = useMutation({
@@ -115,7 +139,7 @@ export default function ExcessBaggagePage() {
label: 'Resend Link',
icon: Send,
variant: 'secondary' as const,
onClick: (c: any) => resendMutation.mutate(c.id),
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
show: (c: any) => c.status === 'PENDING',
},
{
@@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
</div>
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}>
Log Excess Luggage
</ActionButton>
</div>
<div className="card">
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() {
emptyMessage="No excess baggage charges found"
/>
{/* Log Excess Luggage Modal */}
<Modal isOpen={logModal} onClose={() => setLogModal(false)} title="Log Excess Luggage" size="sm">
<div className="space-y-4">
{user && (
<div className="text-sm text-muted-foreground">
Logging as agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>
<label className="label">Booking ID</label>
<input
className="input"
placeholder="Booking UUID"
value={logForm.bookingId}
onChange={(e) => setLogForm({ ...logForm, bookingId: e.target.value })}
/>
</div>
<div>
<label className="label">Excess Weight (kg)</label>
<input
type="number"
min="1"
className="input"
placeholder="e.g. 5"
value={logForm.excessWeightKg}
onChange={(e) => setLogForm({ ...logForm, excessWeightKg: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={logForm.collectCash}
onChange={(e) => setLogForm({ ...logForm, collectCash: e.target.checked })}
/>
Collect cash now (no payment link sent)
</label>
{!logForm.collectCash && (
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's email and phone on file.
</p>
)}
{logError && <p className="text-sm text-red-600 dark:text-red-400">{logError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setLogModal(false)}>Cancel</ActionButton>
<ActionButton
loading={logMutation.isPending}
onClick={() => {
if (!logForm.bookingId.trim() || !logForm.excessWeightKg) {
setLogError('Booking ID and excess weight are required');
return;
}
logMutation.mutate({
bookingId: logForm.bookingId.trim(),
excessWeightKg: parseInt(logForm.excessWeightKg),
collectCash: logForm.collectCash,
});
}}
>
{logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'}
</ActionButton>
</div>
</div>
</Modal>
{/* Resend Link Modal */}
<Modal isOpen={!!resendModal} onClose={() => setResendModal(null)} title="Resend Payment Link" size="sm">
{resendModal && (
<div className="space-y-4">
{resendSuccess ? (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4 text-sm text-green-800 dark:text-green-200">
✓ Payment link resent successfully. Expiry extended by 20 minutes.
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
Resend payment link for booking{' '}
<span className="font-mono font-semibold text-foreground">{resendModal.booking?.bookingRef}</span>?
</p>
<div className="text-sm space-y-1">
{resendModal.contactPhone && <div className="text-muted-foreground">📱 {resendModal.contactPhone}</div>}
{resendModal.contactEmail && <div className="text-muted-foreground">✉ {resendModal.contactEmail}</div>}
</div>
<p className="text-xs text-muted-foreground">Amount: <span className="font-semibold">{formatCurrency(resendModal.totalMinor, resendModal.currency)}</span>. Expiry will be extended by 20 minutes.</p>
{resendError && <p className="text-sm text-red-600 dark:text-red-400">{resendError}</p>}
</>
)}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setResendModal(null)}>Close</ActionButton>
{!resendSuccess && (
<ActionButton icon={Send} loading={resendMutation.isPending} onClick={() => resendMutation.mutate(resendModal.id)}>
Resend
</ActionButton>
)}
</div>
</div>
)}
</Modal>
{/* Waive Modal */}
<Modal
isOpen={!!waiveModal}

View File

@@ -9,6 +9,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
import { routeCoachTemplatesApi } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
interface Schedule {
id: string;
@@ -440,7 +441,7 @@ export default function SchedulesPage() {
label: 'Departure',
sortable: true,
render: (schedule: Schedule) => (
<span className="font-mono text-sm">{new Date(schedule.departureAt).toLocaleString()}</span>
<span className="font-mono text-sm">{formatDateTime(schedule.departureAt)}</span>
),
},
{
@@ -448,7 +449,7 @@ export default function SchedulesPage() {
label: 'Arrival',
sortable: true,
render: (schedule: Schedule) => (
<span className="font-mono text-sm">{new Date(schedule.arrivalAt).toLocaleString()}</span>
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
),
},
{
@@ -641,7 +642,7 @@ export default function SchedulesPage() {
}
}}
title="Cancel Schedule"
message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`}
message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`}
confirmText="Cancel Schedule"
isDanger={true}
isLoading={cancelScheduleMutation.isPending}
@@ -656,7 +657,7 @@ export default function SchedulesPage() {
deleteConfirm.isBulk
? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.`
: `Are you sure you want to delete this schedule departing on ${
deleteConfirm.item ? new Date(deleteConfirm.item.departureAt).toLocaleString() : ''
deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : ''
}?`
}
confirmText="Delete"

View File

@@ -169,7 +169,7 @@ export default function TariffRatesPage() {
render: (c: any) => <span className="text-sm">{c.coachType?.name || c.coachTypeId}</span>,
},
{
key: 'bedPosition', label: 'Berth Position',
key: 'bedPosition', label: 'Bed Position',
render: (c: any) => c.bedPosition
? <span className="font-mono text-sm">{c.bedPosition}</span>
: <span className="text-muted-foreground text-xs">Standard</span>,
@@ -215,8 +215,9 @@ export default function TariffRatesPage() {
},
];
const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId);
const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC';
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">
@@ -224,7 +225,7 @@ export default function TariffRatesPage() {
<div>
<h1 className="text-2xl font-bold text-foreground">Tariff Rates</h1>
<p className="text-muted-foreground">
Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy
Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy
</p>
</div>
<ActionButton icon={Plus} onClick={() => { setEditingClass(null); setFormError(null); setShowModal(true); }}>
@@ -237,7 +238,7 @@ export default function TariffRatesPage() {
<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, berth position..."
placeholder="Search by name, nationality, bed position..."
className="input pl-10 w-full"
value={search}
onChange={(e) => setSearch(e.target.value)}
@@ -311,17 +312,17 @@ export default function TariffRatesPage() {
{isBedCoach && (
<div>
<label className="label">Berth Position *</label>
<label className="label">Bed Position *</label>
<select
className="input"
value={selectedBedPosition}
onChange={(e) => setSelectedBedPosition(e.target.value)}
required={isBedCoach}
>
<option value="">Select berth position</option>
<option value="">Select bed position</option>
{(selectedCoachType?.code === 'HBC'
? BED_POSITIONS
: (['UPPER', 'LOWER'] as const)
: (['Upper','Middle', 'Lower'] as const)
).map((pos) => (
<option key={pos} value={pos}>{pos}</option>
))}

View File

@@ -38,13 +38,6 @@ export default function TicketsPage() {
const [excessError, setExcessError] = useState<string | null>(null);
const [excessResult, setExcessResult] = useState<any>(null);
const { data: agentData } = useQuery({
queryKey: ['agent-me'],
queryFn: () => apiClient.get<any>('/agents/me'),
enabled: !!user,
retry: false,
});
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{label}</p>
@@ -133,11 +126,8 @@ export default function TicketsPage() {
const handleExcessSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!excessTicket) return;
const agentId = agentData?.id;
if (!agentId) { setExcessError('No agent profile found for your account'); return; }
await excessMutation.mutateAsync({
bookingId: excessTicket.booking?.id ?? excessTicket.bookingId,
agentId,
excessWeightKg: parseInt(excessKg),
collectCash: excessCollectCash,
});
@@ -486,7 +476,7 @@ export default function TicketsPage() {
const actions = [
{
label: 'Baggage',
label: 'Luggage',
onClick: openExcessModal,
variant: 'secondary' as const,
icon: Package,
@@ -912,14 +902,9 @@ export default function TicketsPage() {
<div className="text-sm text-muted-foreground">
Booking: <span className="font-semibold text-foreground">{excessTicket?.booking?.bookingRef}</span>
</div>
{agentData && (
{user && (
<div className="text-sm text-muted-foreground">
Agent: <span className="font-semibold text-foreground">{agentData.agentCode}</span>
</div>
)}
{!agentData && (
<div className="text-sm text-amber-600 dark:text-amber-400">
No agent profile linked to your account.
Agent: <span className="font-semibold text-foreground">{user.fullName}</span>
</div>
)}
<div>

View File

@@ -216,6 +216,7 @@ export const agentsApi = {
getById: (id: string) => apiClient.get<any>(`/agents/${id}`),
create: (data: any) => apiClient.post<any>('/agents', data),
update: (id: string, data: any) => apiClient.patch<any>(`/agents/${id}`, data),
delete: (id: string) => apiClient.delete(`/agents/${id}`),
getShifts: (agentId: string) => apiClient.get<any[]>(`/agents/${agentId}/shifts`),
openShift: (agentId: string, data: any) => apiClient.post<any>(`/agents/${agentId}/shifts/open`, data),
closeShift: (shiftId: string, data: any) => apiClient.post<any>(`/agents/shifts/${shiftId}/close`, data),

View File

@@ -19,21 +19,21 @@ export const formatDateTime = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
return format(d, 'MMM dd, yyyy h:mm a');
};
export const formatDateTimeShort = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'dd MMM yy HH:mm');
return format(d, 'dd MMM yy h:mm a');
};
export const formatDateTimeLocal = (date?: string | Date | null): string => {
if (!date) return 'N/A';
const d = new Date(date);
if (isNaN(d.getTime())) return 'N/A';
return format(d, 'MMM dd, yyyy HH:mm');
return format(d, 'MMM dd, yyyy h:mm a');
};
export const getStatusColor = (status: string): string => {

View File

@@ -13,6 +13,30 @@ import { gregorianToEthiopian, ethiopianToGregorian, ETHIOPIAN_MONTHS, getDaysIn
const GC_MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const COUNTRIES = [
'Afghanistan','Albania','Algeria','Andorra','Angola','Antigua and Barbuda','Argentina','Armenia','Australia','Austria',
'Azerbaijan','Bahamas','Bahrain','Bangladesh','Barbados','Belarus','Belgium','Belize','Benin','Bhutan',
'Bolivia','Bosnia and Herzegovina','Botswana','Brazil','Brunei','Bulgaria','Burkina Faso','Burundi','Cabo Verde','Cambodia',
'Cameroon','Canada','Central African Republic','Chad','Chile','China','Colombia','Comoros','Congo','Costa Rica',
'Croatia','Cuba','Cyprus','Czech Republic','Denmark','Dominica','Dominican Republic','Ecuador','Egypt',
'El Salvador','Equatorial Guinea','Eritrea','Estonia','Eswatini','Fiji','Finland','France','Gabon',
'Gambia','Georgia','Germany','Ghana','Greece','Grenada','Guatemala','Guinea','Guinea-Bissau','Guyana',
'Haiti','Honduras','Hungary','Iceland','India','Indonesia','Iran','Iraq','Ireland','Israel',
'Italy','Jamaica','Japan','Jordan','Kazakhstan','Kenya','Kiribati','Kuwait','Kyrgyzstan','Laos',
'Latvia','Lebanon','Lesotho','Liberia','Libya','Liechtenstein','Lithuania','Luxembourg','Madagascar','Malawi',
'Malaysia','Maldives','Mali','Malta','Marshall Islands','Mauritania','Mauritius','Mexico','Micronesia','Moldova',
'Monaco','Mongolia','Montenegro','Morocco','Mozambique','Myanmar','Namibia','Nauru','Nepal','Netherlands',
'New Zealand','Nicaragua','Niger','Nigeria','North Korea','North Macedonia','Norway','Oman','Pakistan','Palau',
'Palestine','Panama','Papua New Guinea','Paraguay','Peru','Philippines','Poland','Portugal','Qatar','Romania',
'Russia','Rwanda','Saint Kitts and Nevis','Saint Lucia','Saint Vincent and the Grenadines','Samoa','San Marino',
'Sao Tome and Principe','Saudi Arabia','Senegal','Serbia','Seychelles','Sierra Leone','Singapore','Slovakia',
'Slovenia','Solomon Islands','Somalia','South Africa','South Korea','South Sudan','Spain','Sri Lanka','Sudan',
'Suriname','Sweden','Switzerland','Syria','Taiwan','Tajikistan','Tanzania','Thailand','Timor-Leste','Togo',
'Tonga','Trinidad and Tobago','Tunisia','Turkey','Turkmenistan','Tuvalu','Uganda','Ukraine','United Arab Emirates',
'United Kingdom','United States','Uruguay','Uzbekistan','Vanuatu','Vatican City','Venezuela','Vietnam',
'Yemen','Zambia','Zimbabwe',
] as const;
function daysInGCMonth(y: number, m: number) {
return new Date(y, m, 0).getDate();
}
@@ -554,6 +578,12 @@ const passengerSchema = z.object({
if (!data.passportCountry || data.passportCountry.trim().length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Issuing country is required', path: ['passportCountry'] });
}
if (data.passportExpiryDate) {
const expiry = new Date(data.passportExpiryDate);
if (!isNaN(expiry.getTime()) && expiry <= new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Passport expiry date must be in the future', path: ['passportExpiryDate'] });
}
}
}
});
@@ -1312,11 +1342,15 @@ export default function PassengersPage() {
<div>
<label className="block text-sm font-medium mb-1 text-gray-700 dark:text-gray-300">Issuing Country *</label>
<input
<select
{...register(`passengers.${index}.passportCountry`)}
className={`input-field ${errors.passengers?.[index]?.passportCountry ? 'border-red-500' : ''}`}
placeholder="e.g., Djibouti"
/>
>
<option value="">Select country</option>
{COUNTRIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
{errors.passengers?.[index]?.passportCountry && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportCountry?.message}</p>
)}
@@ -1336,8 +1370,12 @@ export default function PassengersPage() {
<input
type="date"
{...register(`passengers.${index}.passportExpiryDate`)}
className="input-field"
className={`input-field ${errors.passengers?.[index]?.passportExpiryDate ? 'border-red-500' : ''}`}
min={new Date().toISOString().split('T')[0]}
/>
{errors.passengers?.[index]?.passportExpiryDate && (
<p className="text-red-500 text-xs mt-1">{errors.passengers[index]?.passportExpiryDate?.message}</p>
)}
</div>
</div>
</div>

View File

@@ -197,11 +197,11 @@ export default function ResultsPage() {
// Alternatives are surfaced whenever a leg returns no exact-date results.
const alternativeOutbound: Schedule[] =
!!results && outboundSchedules.length === 0
? results?.alternativeOutbound || []
? results?.alternativeOutbound || results?.outboundAlternatives || []
: [];
const alternativeInbound: Schedule[] =
isRoundTrip && !!results && inboundSchedules.length === 0
? results?.alternativeInbound || []
? results?.alternativeInbound || results?.inboundAlternatives || []
: [];
const requestedDate: string =
(results && results.requestedDate) || searchData.date;
@@ -927,28 +927,19 @@ export default function ResultsPage() {
!!results &&
outboundSchedules.length === 0 &&
inboundSchedules.length === 0 &&
alternativeOutbound.length === 0 &&
alternativeInbound.length === 0;
(results?.alternativeOutbound || []).length === 0 &&
(results?.alternativeInbound || []).length === 0;
if (isRoundTripNoResults) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains found for your selected dates or route.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains for your trip. Try adjusting
your dates or route.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
</div>
@@ -969,24 +960,13 @@ export default function ResultsPage() {
{renderClassModal()}
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="card text-center mb-8 max-w-3xl mx-auto">
<div className="w-20 h-20 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-8">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains available on <span className="font-semibold">{requestedDateLabel}</span>.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedDateLabel}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel date
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change date
</button>
</div>
@@ -1017,22 +997,13 @@ export default function ResultsPage() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12">
<div className="container mx-auto px-4">
<div className="max-w-2xl mx-auto">
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-6">
<Calendar className="w-10 h-10 text-gray-400 dark:text-gray-500" />
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2.5 text-sm text-gray-600 dark:text-gray-400">
<Calendar className="w-4 h-4 flex-shrink-0 text-gray-400" />
<span>No trains found matching your search. Try adjusting your dates or route.</span>
</div>
<h2 className="text-2xl font-bold mb-3 text-gray-900 dark:text-gray-100">
No trains found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
We couldn&apos;t find any trains matching your search criteria.{" "}
<br /> Try adjusting your dates or route.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Modify search
</button>
</div>
@@ -1156,29 +1127,13 @@ export default function ResultsPage() {
{outboundSchedules.length === 0 &&
alternativeOutbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{requestedDate ? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedDate
? format(
new Date(`${requestedDate}T00:00:00`),
"EEEE, MMMM d, yyyy",
)
: "your selected date"}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel dates
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">
@@ -1256,29 +1211,13 @@ export default function ResultsPage() {
{inboundSchedules.length === 0 &&
alternativeInbound.length > 0 && (
<div className="mt-6">
<div className="card text-center mb-6 max-w-3xl mx-auto">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Calendar className="w-8 h-8 text-amber-500" />
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-6">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
<h2 className="text-xl font-bold mb-2 text-gray-900 dark:text-gray-100">
No trains available
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
No trains are available on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{requestedReturnDate
? format(
new Date(`${requestedReturnDate}T00:00:00`),
"EEEE, MMMM d, yyyy",
)
: "your selected return date"}
</span>
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary"
>
Change travel dates
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
Change dates
</button>
</div>
<div className="mb-3">