mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -33,6 +33,7 @@ import {
|
||||
PaymentMethodTypeEnum,
|
||||
PaymentPlatformDto,
|
||||
BookingAmountResponseDto,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -124,6 +125,19 @@ export class PaymentsController {
|
||||
return this.service.refund(dto);
|
||||
}
|
||||
|
||||
@Post(":bookingId/force-confirm")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Force-confirm payment & generate ticket (back-office only)",
|
||||
description:
|
||||
"Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " +
|
||||
"Use when a vendor payment completed but the webhook was never delivered. Idempotent.",
|
||||
})
|
||||
forceConfirm(@Param("bookingId") bookingId: string, @Body() dto: ForceConfirmDto) {
|
||||
return this.service.forceConfirmPayment(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post("methods")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
|
||||
@@ -142,3 +142,14 @@ export class BookingAmountResponseDto {
|
||||
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
|
||||
@ApiProperty({ example: 162.5, description: 'Booking total converted to the requested currency (major units)' }) amount: number;
|
||||
}
|
||||
|
||||
export class ForceConfirmDto {
|
||||
@ApiPropertyOptional({ description: 'External payment reference / transaction ID from the vendor', example: 'TXN-123456' })
|
||||
@IsOptional() @IsString() paymentReference?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PaymentMethodTypeEnum, description: 'Payment method used externally', example: 'TELEBIRR' })
|
||||
@IsOptional() @IsEnum(PaymentMethodTypeEnum) paymentMethod?: PaymentMethodTypeEnum;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Internal notes about why this was force-confirmed', example: 'Vendor confirmed via phone' })
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
InitiateResponseDto,
|
||||
IntentStatusDto,
|
||||
PaymentRegionEnum,
|
||||
ForceConfirmDto,
|
||||
} from "./payments.dto";
|
||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||
import { PaymentClientService } from "./payment-client.service";
|
||||
@@ -773,6 +774,51 @@ export class PaymentsService {
|
||||
return { processed: true, alreadyFinalized };
|
||||
}
|
||||
|
||||
async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
const resolvedMethod = dto.paymentMethod
|
||||
? (dto.paymentMethod as unknown as PaymentMethodType)
|
||||
: PaymentMethodType.TELEBIRR;
|
||||
|
||||
let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
|
||||
if (!intent) {
|
||||
intent = await this.prisma.paymentIntent.create({
|
||||
data: {
|
||||
bookingId,
|
||||
amountMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
method: resolvedMethod,
|
||||
status: PaymentIntentStatus.PROCESSING,
|
||||
providerRef: `FORCE-${Date.now()}`,
|
||||
providerTxnId: dto.paymentReference ?? null,
|
||||
failureMessage: dto.notes ?? null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Update method/reference/notes regardless of current status
|
||||
const updateData: any = {};
|
||||
if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
|
||||
if (dto.paymentMethod) updateData.method = resolvedMethod;
|
||||
if (dto.notes) updateData.failureMessage = dto.notes;
|
||||
if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
|
||||
updateData.status = PaymentIntentStatus.PROCESSING;
|
||||
}
|
||||
if (Object.keys(updateData).length) {
|
||||
intent = await this.prisma.paymentIntent.update({
|
||||
where: { id: intent.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.finalizePaymentSuccess({
|
||||
intentId: intent.id,
|
||||
providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async markPaymentFailed(input: {
|
||||
intentId: string;
|
||||
failureCode?: string;
|
||||
|
||||
@@ -258,8 +258,8 @@ export class TicketsService {
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
seat: ps.seat.seatNumber,
|
||||
coach: ps.seat.coach.number,
|
||||
seat: ps.seat?.seatNumber,
|
||||
coach: ps.seat?.coach?.number,
|
||||
leg: ps.leg || 1,
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, Trash2 } from 'lucide-react';
|
||||
import { Download, Eye, Trash2, Ticket } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
@@ -35,6 +35,8 @@ function BookingsPageContent() {
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
|
||||
const [generateTicketForm, setGenerateTicketForm] = useState({ paymentReference: '', paymentMethod: '', notes: '' });
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
@@ -63,6 +65,19 @@ function BookingsPageContent() {
|
||||
}),
|
||||
});
|
||||
|
||||
const forceConfirmMutation = useMutation({
|
||||
mutationFn: ({ bookingId, data }: { bookingId: string; data: { paymentReference?: string; paymentMethod?: string; notes?: string } }) =>
|
||||
bookingsApi.forceConfirm(bookingId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
setSuccessMessage('Payment confirmed and ticket generated successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 4000);
|
||||
setSelectedBooking(null);
|
||||
setGenerateTicketBooking(null);
|
||||
setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
|
||||
onSuccess: () => {
|
||||
@@ -258,6 +273,7 @@ function BookingsPageContent() {
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
|
||||
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
|
||||
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
||||
];
|
||||
|
||||
@@ -448,6 +464,25 @@ function BookingsPageContent() {
|
||||
<Field label="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||
</div>
|
||||
{b.paymentIntent?.status !== 'SUCCEEDED' && canManage && (
|
||||
<div className="mt-3 p-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mb-2">
|
||||
Payment not confirmed by vendor. If you have verified the payment was completed externally, force-confirm to confirm the booking and generate the ticket.
|
||||
</p>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => forceConfirmMutation.mutate({ bookingId: b.id, data: {} })}
|
||||
disabled={forceConfirmMutation.isPending}
|
||||
>
|
||||
{forceConfirmMutation.isPending ? 'Confirming…' : 'Force Confirm & Generate Ticket'}
|
||||
</ActionButton>
|
||||
{forceConfirmMutation.isError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-2">
|
||||
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Seats / Passengers */}
|
||||
@@ -517,6 +552,93 @@ function BookingsPageContent() {
|
||||
onCascadeChange={(checked) => setDeleteCascadeChecked(checked)}
|
||||
/>
|
||||
|
||||
{/* Generate Ticket Modal */}
|
||||
<Modal
|
||||
isOpen={!!generateTicketBooking}
|
||||
onClose={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
|
||||
title="Generate Ticket"
|
||||
size="md"
|
||||
>
|
||||
{generateTicketBooking && (
|
||||
<div className="space-y-4">
|
||||
<div className="-mx-6 -mt-4 mb-4 px-6 py-4 bg-muted/40 border-b border-muted">
|
||||
<p className="text-xs text-muted-foreground">Booking</p>
|
||||
<p className="font-mono font-bold text-lg">{generateTicketBooking.bookingRef}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{generateTicketBooking.schedule?.originStation?.name} → {generateTicketBooking.schedule?.destinationStation?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Payment Reference / Transaction ID</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. TXN-123456 or vendor receipt number"
|
||||
value={generateTicketForm.paymentReference}
|
||||
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentReference: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Payment Method</label>
|
||||
<select
|
||||
className="input"
|
||||
value={generateTicketForm.paymentMethod}
|
||||
onChange={(e) => setGenerateTicketForm(f => ({ ...f, paymentMethod: e.target.value }))}
|
||||
>
|
||||
<option value="">Select method…</option>
|
||||
<option value="TELEBIRR">Telebirr</option>
|
||||
<option value="CBE_BIRR">CBE Birr</option>
|
||||
<option value="EBIRR">eBirr</option>
|
||||
<option value="WAAFI">Waafi</option>
|
||||
<option value="DMONEY">dMoney</option>
|
||||
<option value="CARD">Card</option>
|
||||
<option value="WALLET">Wallet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Notes <span className="text-muted-foreground font-normal">(optional)</span></label>
|
||||
<textarea
|
||||
className="input min-h-[72px] resize-none"
|
||||
placeholder="e.g. Customer paid at counter, receipt #123"
|
||||
value={generateTicketForm.notes}
|
||||
onChange={(e) => setGenerateTicketForm(f => ({ ...f, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{forceConfirmMutation.isError && (
|
||||
<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-700 dark:text-red-400">
|
||||
{(() => { const e = forceConfirmMutation.error as any; const m = e?.response?.data?.message; return Array.isArray(m) ? m.join(', ') : m || e?.message || 'Failed to confirm payment'; })()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
onClick={() => { setGenerateTicketBooking(null); setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); forceConfirmMutation.reset(); }}
|
||||
>
|
||||
Cancel
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => forceConfirmMutation.mutate({
|
||||
bookingId: generateTicketBooking.id,
|
||||
data: {
|
||||
paymentReference: generateTicketForm.paymentReference || undefined,
|
||||
paymentMethod: generateTicketForm.paymentMethod || undefined,
|
||||
notes: generateTicketForm.notes || undefined,
|
||||
},
|
||||
})}
|
||||
disabled={forceConfirmMutation.isPending}
|
||||
>
|
||||
{forceConfirmMutation.isPending ? 'Generating…' : 'Confirm & Generate Ticket'}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { seatsApi, schedulesApi, fleetApi } from '@/lib/api';
|
||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
||||
import { routesApi } from '@/lib/api/routes';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import ActionButton from '@/components/ui/ActionButton'
|
||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
||||
|
||||
export default function SeatsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||
const [selectedRoute, setSelectedRoute] = useState('');
|
||||
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
|
||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||
@@ -39,6 +42,30 @@ export default function SeatsPage() {
|
||||
queryFn: () => fleetApi.getCoaches(),
|
||||
});
|
||||
|
||||
const { data: routesData } = useQuery({
|
||||
queryKey: ['routes'],
|
||||
queryFn: () => routesApi.getAll(),
|
||||
});
|
||||
|
||||
const { data: routeCoachesData, isLoading: routeCoachesLoading } = useQuery({
|
||||
queryKey: ['routeCoaches', selectedRoute],
|
||||
queryFn: async () => {
|
||||
if (!selectedRoute) return null;
|
||||
const template: any[] = await routeCoachTemplatesApi.get(selectedRoute);
|
||||
if (!template?.length) return [];
|
||||
const fullCoaches = await Promise.all(
|
||||
template.map((entry: any) => fleetApi.getCoach(entry.coachId ?? entry.coach?.id))
|
||||
);
|
||||
return fullCoaches.map((coach: any, i: number) => ({
|
||||
...coach,
|
||||
coachNumber: coach.number,
|
||||
positionNumber: template[i].positionNumber,
|
||||
seatArrangement: coach.arrangement,
|
||||
}));
|
||||
},
|
||||
enabled: !!selectedRoute,
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||
onSuccess: () => {
|
||||
@@ -89,7 +116,8 @@ export default function SeatsPage() {
|
||||
});
|
||||
|
||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
||||
const coaches = seatMapData?.coaches || [];
|
||||
const routes = routesData?.items || routesData?.data || [];
|
||||
const coaches = activeTab === 'schedule' ? (seatMapData?.coaches || []) : (Array.isArray(routeCoachesData) ? routeCoachesData : []);
|
||||
|
||||
const blockCoachMutation = useMutation({
|
||||
mutationFn: async ({ coachId, reason }: any) => {
|
||||
@@ -464,6 +492,9 @@ export default function SeatsPage() {
|
||||
return seqA - seqB;
|
||||
});
|
||||
|
||||
const activeSelection = activeTab === 'schedule' ? selectedSchedule : selectedRoute;
|
||||
const isLoadingData = activeTab === 'schedule' ? isLoading : routeCoachesLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -471,31 +502,62 @@ export default function SeatsPage() {
|
||||
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
||||
</div>
|
||||
|
||||
{!selectedSchedule ? (
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 bg-muted rounded-lg w-fit">
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('route')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'route'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
By Route
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('schedule')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'schedule'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
By Schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!activeSelection ? (
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{date} - {routeName}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{activeTab === 'schedule' ? (
|
||||
<>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return <option key={schedule.id} value={schedule.id}>{date} - {routeName}</option>;
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">Select Route</label>
|
||||
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||
<option value="">Select a route...</option>
|
||||
{routes.map((route: any) => (
|
||||
<option key={route.id} value={route.id}>{route.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
<div className="text-center py-12 text-muted-foreground mt-8">
|
||||
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Select a schedule to view seat map</p>
|
||||
<p>Select a {activeTab === 'schedule' ? 'schedule' : 'route'} to view seat map</p>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
) : isLoadingData ? (
|
||||
<div className="card text-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||
@@ -503,51 +565,63 @@ export default function SeatsPage() {
|
||||
) : coachesWithSeats.length === 0 ? (
|
||||
<div className="space-y-6">
|
||||
<div className="card">
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{activeTab === 'schedule' ? (
|
||||
<>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">Select Route</label>
|
||||
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||
<option value="">Select a route...</option>
|
||||
{routes.map((route: any) => (
|
||||
<option key={route.id} value={route.id}>{route.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="card text-center py-12 text-muted-foreground">
|
||||
<p>No coaches with seats found for this schedule</p>
|
||||
<p>No coaches with seats found for this {activeTab === 'schedule' ? 'schedule' : 'route'}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card h-fit sticky top-6 space-y-6">
|
||||
<div>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select
|
||||
value={selectedSchedule}
|
||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
||||
className="input"
|
||||
>
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return (
|
||||
<option key={schedule.id} value={schedule.id}>
|
||||
{trainNumber} - {routeName} - {date}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{activeTab === 'schedule' ? (
|
||||
<>
|
||||
<label className="label">Select Schedule</label>
|
||||
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||
<option value="">Select a schedule...</option>
|
||||
{schedules.map((schedule: any) => {
|
||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||
const routeName = schedule.route?.name || 'N/A';
|
||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
||||
})}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">Select Route</label>
|
||||
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||
<option value="">Select a route...</option>
|
||||
{routes.map((route: any) => (
|
||||
<option key={route.id} value={route.id}>{route.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -28,4 +28,7 @@ export const bookingsApi = {
|
||||
cancel: (id: string, reason?: string) => {
|
||||
return apiClient.post<Booking>(`/bookings/${id}/cancel`, { reason });
|
||||
},
|
||||
|
||||
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
||||
apiClient.post(`/payments/${bookingId}/force-confirm`, data),
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ export const bookingsApi = {
|
||||
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
|
||||
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
|
||||
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
||||
forceConfirm: (bookingId: string, data: { paymentReference?: string; paymentMethod?: string; notes?: string }) =>
|
||||
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
|
||||
};
|
||||
|
||||
// Passengers API
|
||||
@@ -115,6 +117,7 @@ export const fleetApi = {
|
||||
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
|
||||
deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
|
||||
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
|
||||
getCoach: (id: string) => apiClient.get<any>(`/fleet/coaches/${id}`),
|
||||
};
|
||||
|
||||
// Schedules API
|
||||
|
||||
Reference in New Issue
Block a user