mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
Manuel ticket generation, seat management by route added
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
|||||||
PaymentMethodTypeEnum,
|
PaymentMethodTypeEnum,
|
||||||
PaymentPlatformDto,
|
PaymentPlatformDto,
|
||||||
BookingAmountResponseDto,
|
BookingAmountResponseDto,
|
||||||
|
ForceConfirmDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { PassengerStaff } from "../../common/passenger-guards";
|
import { PassengerStaff } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
@@ -124,6 +125,19 @@ export class PaymentsController {
|
|||||||
return this.service.refund(dto);
|
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")
|
@Post("methods")
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
|
|||||||
@@ -142,3 +142,14 @@ export class BookingAmountResponseDto {
|
|||||||
@ApiProperty({ example: 'DJF', description: 'Currency of the returned amount' }) currency: string;
|
@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;
|
@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,
|
InitiateResponseDto,
|
||||||
IntentStatusDto,
|
IntentStatusDto,
|
||||||
PaymentRegionEnum,
|
PaymentRegionEnum,
|
||||||
|
ForceConfirmDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
@@ -773,6 +774,51 @@ export class PaymentsService {
|
|||||||
return { processed: true, alreadyFinalized };
|
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: {
|
async markPaymentFailed(input: {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
|
|||||||
@@ -258,8 +258,8 @@ export class TicketsService {
|
|||||||
type: booking.bookingType,
|
type: booking.bookingType,
|
||||||
passenger: passengerName,
|
passenger: passengerName,
|
||||||
seats: passengerSeats.map(ps => ({
|
seats: passengerSeats.map(ps => ({
|
||||||
seat: ps.seat.seatNumber,
|
seat: ps.seat?.seatNumber,
|
||||||
coach: ps.seat.coach.number,
|
coach: ps.seat?.coach?.number,
|
||||||
leg: ps.leg || 1,
|
leg: ps.leg || 1,
|
||||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 DataTable from '@/components/ui/DataTable';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
@@ -35,6 +35,8 @@ function BookingsPageContent() {
|
|||||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
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 [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
const [bookingToDelete, setBookingToDelete] = useState<any>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
|
mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/bookings/${id}${cascade ? '?cascade=true' : ''}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -258,6 +273,7 @@ function BookingsPageContent() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
|
{ 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 },
|
{ 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="Display Currency" value={b.displayCurrency || b.currency || 'ETB'} />
|
||||||
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
<Field label="Payment ID" value={b.paymentIntent?.id || '—'} mono truncate />
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* Seats / Passengers */}
|
{/* Seats / Passengers */}
|
||||||
@@ -517,6 +552,93 @@ function BookingsPageContent() {
|
|||||||
onCascadeChange={(checked) => setDeleteCascadeChecked(checked)}
|
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">
|
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Bookings" size="md">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 Modal from '@/components/ui/Modal';
|
||||||
import ActionButton from '@/components/ui/ActionButton'
|
import ActionButton from '@/components/ui/ActionButton'
|
||||||
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench } from 'lucide-react';
|
||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState('');
|
const [selectedSchedule, setSelectedSchedule] = useState('');
|
||||||
|
const [selectedRoute, setSelectedRoute] = useState('');
|
||||||
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
|
const [expandedCoaches, setExpandedCoaches] = useState<Set<string>>(new Set());
|
||||||
const [showBlockModal, setShowBlockModal] = useState(false);
|
const [showBlockModal, setShowBlockModal] = useState(false);
|
||||||
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
@@ -39,6 +42,30 @@ export default function SeatsPage() {
|
|||||||
queryFn: () => fleetApi.getCoaches(),
|
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({
|
const blockMutation = useMutation({
|
||||||
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
mutationFn: ({ seatId, reason }: any) => seatsApi.block(seatId, { reason }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -89,7 +116,8 @@ export default function SeatsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const schedules = schedulesData?.items || schedulesData?.data || [];
|
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({
|
const blockCoachMutation = useMutation({
|
||||||
mutationFn: async ({ coachId, reason }: any) => {
|
mutationFn: async ({ coachId, reason }: any) => {
|
||||||
@@ -464,6 +492,9 @@ export default function SeatsPage() {
|
|||||||
return seqA - seqB;
|
return seqA - seqB;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeSelection = activeTab === 'schedule' ? selectedSchedule : selectedRoute;
|
||||||
|
const isLoadingData = activeTab === 'schedule' ? isLoading : routeCoachesLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -471,31 +502,62 @@ export default function SeatsPage() {
|
|||||||
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
<p className="text-muted-foreground mt-1">View and manage seats by coach</p>
|
||||||
</div>
|
</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">
|
<div className="card">
|
||||||
<label className="label">Select Schedule</label>
|
{activeTab === 'schedule' ? (
|
||||||
<select
|
<>
|
||||||
value={selectedSchedule}
|
<label className="label">Select Schedule</label>
|
||||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||||
className="input"
|
<option value="">Select a schedule...</option>
|
||||||
>
|
{schedules.map((schedule: any) => {
|
||||||
<option value="">Select a schedule...</option>
|
const routeName = schedule.route?.name || 'N/A';
|
||||||
{schedules.map((schedule: any) => {
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||||
const routeName = schedule.route?.name || 'N/A';
|
return <option key={schedule.id} value={schedule.id}>{date} - {routeName}</option>;
|
||||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
})}
|
||||||
return (
|
</select>
|
||||||
<option key={schedule.id} value={schedule.id}>
|
</>
|
||||||
{date} - {routeName}
|
) : (
|
||||||
</option>
|
<>
|
||||||
);
|
<label className="label">Select Route</label>
|
||||||
})}
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||||
</select>
|
<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">
|
<div className="text-center py-12 text-muted-foreground mt-8">
|
||||||
<Armchair className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : isLoading ? (
|
) : isLoadingData ? (
|
||||||
<div className="card text-center py-12">
|
<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>
|
<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>
|
<p className="text-muted-foreground mt-3">Loading seats...</p>
|
||||||
@@ -503,51 +565,63 @@ export default function SeatsPage() {
|
|||||||
) : coachesWithSeats.length === 0 ? (
|
) : coachesWithSeats.length === 0 ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<label className="label">Select Schedule</label>
|
{activeTab === 'schedule' ? (
|
||||||
<select
|
<>
|
||||||
value={selectedSchedule}
|
<label className="label">Select Schedule</label>
|
||||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||||
className="input"
|
<option value="">Select a schedule...</option>
|
||||||
>
|
{schedules.map((schedule: any) => {
|
||||||
<option value="">Select a schedule...</option>
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||||
{schedules.map((schedule: any) => {
|
const routeName = schedule.route?.name || 'N/A';
|
||||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||||
const routeName = schedule.route?.name || 'N/A';
|
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
||||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
})}
|
||||||
return (
|
</select>
|
||||||
<option key={schedule.id} value={schedule.id}>
|
</>
|
||||||
{trainNumber} - {routeName} - {date}
|
) : (
|
||||||
</option>
|
<>
|
||||||
);
|
<label className="label">Select Route</label>
|
||||||
})}
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||||
</select>
|
<option value="">Select a route...</option>
|
||||||
|
{routes.map((route: any) => (
|
||||||
|
<option key={route.id} value={route.id}>{route.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="card text-center py-12 text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="card h-fit sticky top-6 space-y-6">
|
<div className="card h-fit sticky top-6 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Select Schedule</label>
|
{activeTab === 'schedule' ? (
|
||||||
<select
|
<>
|
||||||
value={selectedSchedule}
|
<label className="label">Select Schedule</label>
|
||||||
onChange={(e) => setSelectedSchedule(e.target.value)}
|
<select value={selectedSchedule} onChange={(e) => setSelectedSchedule(e.target.value)} className="input">
|
||||||
className="input"
|
<option value="">Select a schedule...</option>
|
||||||
>
|
{schedules.map((schedule: any) => {
|
||||||
<option value="">Select a schedule...</option>
|
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
||||||
{schedules.map((schedule: any) => {
|
const routeName = schedule.route?.name || 'N/A';
|
||||||
const trainNumber = schedule.train?.trainNumber || schedule.train?.name || 'N/A';
|
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
||||||
const routeName = schedule.route?.name || 'N/A';
|
return <option key={schedule.id} value={schedule.id}>{trainNumber} - {routeName} - {date}</option>;
|
||||||
const date = schedule.departureAt ? new Date(schedule.departureAt).toLocaleDateString() : 'N/A';
|
})}
|
||||||
return (
|
</select>
|
||||||
<option key={schedule.id} value={schedule.id}>
|
</>
|
||||||
{trainNumber} - {routeName} - {date}
|
) : (
|
||||||
</option>
|
<>
|
||||||
);
|
<label className="label">Select Route</label>
|
||||||
})}
|
<select value={selectedRoute} onChange={(e) => setSelectedRoute(e.target.value)} className="input">
|
||||||
</select>
|
<option value="">Select a route...</option>
|
||||||
|
{routes.map((route: any) => (
|
||||||
|
<option key={route.id} value={route.id}>{route.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
<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) => {
|
cancel: (id: string, reason?: string) => {
|
||||||
return apiClient.post<Booking>(`/bookings/${id}/cancel`, { reason });
|
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),
|
cancel: (id: string, data?: any) => apiClient.post<any>(`/bookings/${id}/cancel`, data),
|
||||||
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
|
modify: (id: string, data: any) => apiClient.patch<any>(`/bookings/${id}`, data),
|
||||||
checkUsage: (id: string) => apiClient.get<any>(`/bookings/${id}/usage`),
|
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
|
// Passengers API
|
||||||
@@ -115,6 +117,7 @@ export const fleetApi = {
|
|||||||
updateCoach: (id: string, data: any) => apiClient.patch<any>(`/fleet/coaches/${id}`, data),
|
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' : ''}`),
|
deleteCoach: (id: string, cascade?: boolean) => apiClient.delete(`/fleet/coaches/${id}${cascade ? '?cascade=true' : ''}`),
|
||||||
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
|
generateSeatMap: (data: any) => apiClient.post<any>('/fleet/seatmap/generate', data),
|
||||||
|
getCoach: (id: string) => apiClient.get<any>(`/fleet/coaches/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Schedules API
|
// Schedules API
|
||||||
|
|||||||
Reference in New Issue
Block a user