mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Manuel ticket generation, seat management by route added
This commit is contained in:
@@ -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