mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 21:20:57 +00:00
Luggage processing,, schedule times and tariff related updates
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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'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">
|
||||
|
||||
Reference in New Issue
Block a user