Merge pull request #956 from Tria-plc/tests

Adding ticket generation and booking for staff employees logic
This commit is contained in:
mulish77
2026-07-25 11:22:59 +03:00
committed by GitHub
29 changed files with 2659 additions and 74 deletions

View File

@@ -2,11 +2,11 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } 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';
import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react';
export default function SeatsPage() {
const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route');
@@ -24,6 +24,22 @@ export default function SeatsPage() {
const [coachToUnblock, setCoachToUnblock] = useState<any>(null);
const [showMaintenanceModal, setShowMaintenanceModal] = useState(false);
const [maintenanceReason, setMaintenanceReason] = useState('');
const [showIssueBookingModal, setShowIssueBookingModal] = useState(false);
const [issueBookingCoach, setIssueBookingCoach] = useState<any>(null);
const [issueBookingForm, setIssueBookingForm] = useState({
bookingKind: 'STAFF' as 'STAFF' | 'PASSENGER',
// No seatClassId — the seat's class is already fixed by the reservation; the backend
// resolves it from the seat's own coach type + nationality tier.
passengerName: '',
dateOfBirth: '',
idDocumentType: 'PASSPORT' as 'NATIONAL_ID' | 'PASSPORT',
idDocumentNumber: '',
passportNumber: '',
nationality: '' as '' | 'Ethiopian' | 'Djiboutian' | 'Other',
phone: '',
email: '',
});
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null);
const queryClient = useQueryClient();
const { data: schedulesData } = useQuery({
@@ -105,6 +121,21 @@ export default function SeatsPage() {
},
});
const issueBookingMutation = useMutation({
mutationFn: ({ seatId, data }: { seatId: string; data: any }) =>
bookingsApi.issueFromReservation(seatId, data),
onSuccess: (result: any) => {
invalidateSeatData();
setIssueBookingResult({ payUrl: result?.payUrl });
if (!result?.payUrl) {
// STAFF booking — nothing further to show the admin, close immediately.
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
}
},
});
const removeSeatMutation = useMutation({
mutationFn: (seatId: string) => seatsApi.removeSeat(seatId),
onSuccess: () => {
@@ -186,11 +217,66 @@ export default function SeatsPage() {
};
const handleUnblock = async (seat: any) => {
if (confirm('Are you sure you want to unblock this seat?')) {
if (confirm('Release this reservation and make the seat available to the public?')) {
await unblockMutation.mutateAsync(seat.id);
}
};
const handleIssueBooking = (seat: any, coach: any) => {
if (activeTab !== 'schedule' || !selectedSchedule) {
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
return;
}
setSelectedSeat(seat);
setIssueBookingCoach(coach);
setIssueBookingResult(null);
setIssueBookingForm({
bookingKind: 'STAFF',
passengerName: '',
dateOfBirth: '',
idDocumentType: 'PASSPORT',
idDocumentNumber: '',
passportNumber: '',
nationality: '',
phone: '',
email: '',
});
setShowIssueBookingModal(true);
};
const submitIssueBooking = async () => {
const schedule = schedules.find((s: any) => s.id === selectedSchedule);
if (!schedule?.originStation?.id || !schedule?.destinationStation?.id) {
alert('Could not resolve this schedule\'s origin/destination stations.');
return;
}
if (!issueBookingForm.passengerName.trim() || !issueBookingForm.dateOfBirth) {
alert('Traveler name and date of birth are required.');
return;
}
if (!issueBookingForm.nationality) {
alert('Select a nationality.');
return;
}
if (issueBookingForm.idDocumentType === 'PASSPORT' && !issueBookingForm.passportNumber.trim()) {
alert('Passport number is required.');
return;
}
if (issueBookingForm.bookingKind === 'PASSENGER' && !issueBookingForm.phone.trim()) {
alert('Phone number is required for a passenger booking (used to send the payment link).');
return;
}
await issueBookingMutation.mutateAsync({
seatId: selectedSeat.id,
data: {
scheduleId: selectedSchedule,
originStationId: schedule.originStation.id,
destinationStationId: schedule.destinationStation.id,
...issueBookingForm,
},
});
};
const handleRemoveSeat = (seat: any) => {
setSelectedSeat(seat);
setShowRemoveModal(true);
@@ -252,7 +338,7 @@ export default function SeatsPage() {
const submitBlock = async () => {
if (!blockReason.trim()) {
alert('Please provide a reason for blocking');
alert('Please provide a reason for the reservation');
return;
}
await blockMutation.mutateAsync({ seatId: selectedSeat.id, reason: blockReason });
@@ -350,6 +436,7 @@ export default function SeatsPage() {
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
hideNumber={true}
/>
))}
@@ -445,6 +532,7 @@ export default function SeatsPage() {
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
hideNumber={true}
/>
))}
@@ -467,6 +555,7 @@ export default function SeatsPage() {
handleUndoRemove={handleUndoRemove}
handleSetMaintenance={handleSetMaintenance}
handleClearMaintenance={handleClearMaintenance}
handleIssueBooking={handleIssueBooking}
hideNumber={true}
/>
))}
@@ -749,15 +838,15 @@ export default function SeatsPage() {
setSelectedSeat(null);
setBlockReason('');
}}
title="Block Seat"
title="Reserve Seat"
size="md"
>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Block seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
Reserve seat <strong>{selectedSeat?.seatNumber}</strong> in Coach <strong>{selectedSeat?.coach?.coachNumber}</strong>
</p>
<div>
<label className="label">Reason for Blocking *</label>
<label className="label">Reason for Reservation *</label>
<textarea
className="input"
rows={3}
@@ -782,12 +871,184 @@ export default function SeatsPage() {
loading={blockMutation.isPending}
disabled={!blockReason.trim()}
>
Block Seat
Reserve Seat
</ActionButton>
</div>
</div>
</Modal>
<Modal
isOpen={showIssueBookingModal}
onClose={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
setIssueBookingResult(null);
}}
title="Issue Booking"
size="md"
>
{issueBookingResult?.payUrl ? (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Booking created. A payment link has been sent via SMS to the traveler.
</p>
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
<div className="flex justify-end">
<ActionButton
onClick={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
setIssueBookingResult(null);
}}
>
Done
</ActionButton>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Issue a booking for seat <strong>{selectedSeat?.seatNumber}</strong> in Coach{' '}
<strong>{issueBookingCoach?.coachNumber}</strong>
</p>
<div>
<label className="label">Booking Type *</label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'STAFF' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'STAFF' }))}
>
Staff (no fee)
</button>
<button
type="button"
className={`flex-1 px-3 py-2 rounded border ${issueBookingForm.bookingKind === 'PASSENGER' ? 'bg-blue-600 text-white border-blue-600' : 'border-gray-300 dark:border-gray-600'}`}
onClick={() => setIssueBookingForm((f) => ({ ...f, bookingKind: 'PASSENGER' }))}
>
Passenger (pay via link)
</button>
</div>
</div>
<div>
<label className="label">Traveler Name *</label>
<input
className="input"
value={issueBookingForm.passengerName}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passengerName: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Date of Birth *</label>
<input
type="date"
className="input"
value={issueBookingForm.dateOfBirth}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, dateOfBirth: e.target.value }))}
/>
</div>
<div>
<label className="label">Nationality *</label>
<select
className="input"
value={issueBookingForm.nationality}
onChange={(e) => {
const nationality = e.target.value as 'Ethiopian' | 'Djiboutian' | 'Other';
setIssueBookingForm((f) => ({
...f,
nationality,
// National ID is Ethiopian-only — switch back to Passport for anyone else.
idDocumentType: nationality === 'Ethiopian' ? f.idDocumentType : 'PASSPORT',
}));
}}
>
<option value="">Select nationality...</option>
<option value="Ethiopian">Ethiopian</option>
<option value="Djiboutian">Djiboutian</option>
<option value="Other">Other</option>
</select>
</div>
</div>
<div>
<label className="label">ID Document Type *</label>
<select
className="input"
value={issueBookingForm.idDocumentType}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentType: e.target.value as 'NATIONAL_ID' | 'PASSPORT' }))}
>
<option value="PASSPORT">Passport</option>
{issueBookingForm.nationality === 'Ethiopian' && (
<option value="NATIONAL_ID">National ID</option>
)}
</select>
</div>
{issueBookingForm.idDocumentType === 'NATIONAL_ID' ? (
<div>
<label className="label">National ID Number</label>
<input
className="input"
value={issueBookingForm.idDocumentNumber}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, idDocumentNumber: e.target.value }))}
/>
</div>
) : (
<div>
<label className="label">Passport Number *</label>
<input
className="input"
value={issueBookingForm.passportNumber}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, passportNumber: e.target.value }))}
/>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Phone{issueBookingForm.bookingKind === 'PASSENGER' ? ' * (payment link sent here)' : ''}</label>
<input
className="input"
value={issueBookingForm.phone}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, phone: e.target.value }))}
placeholder="+251911234567"
/>
</div>
<div>
<label className="label">Email</label>
<input
className="input"
value={issueBookingForm.email}
onChange={(e) => setIssueBookingForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<ActionButton
variant="secondary"
onClick={() => {
setShowIssueBookingModal(false);
setSelectedSeat(null);
setIssueBookingCoach(null);
}}
>
Cancel
</ActionButton>
<ActionButton onClick={submitIssueBooking} loading={issueBookingMutation.isPending}>
{issueBookingForm.bookingKind === 'STAFF' ? 'Issue Ticket' : 'Send Payment Link'}
</ActionButton>
</div>
</div>
)}
</Modal>
<Modal
isOpen={showRemoveModal}
onClose={() => {
@@ -973,6 +1234,7 @@ interface SeatIconProps {
handleUndoRemove: (seat: any) => void;
handleSetMaintenance: (seat: any) => void;
handleClearMaintenance: (seat: any) => void;
handleIssueBooking: (seat: any, coach: any) => void;
}
function SeatIcon({
@@ -989,6 +1251,7 @@ function SeatIcon({
handleUndoRemove,
handleSetMaintenance,
handleClearMaintenance,
handleIssueBooking,
}: SeatIconProps) {
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
@@ -1058,7 +1321,7 @@ function SeatIcon({
<button
onClick={() => handleBlock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Block seat"
title="Reserve seat"
>
<Lock className="h-3 w-3 text-gray-700" />
</button>
@@ -1072,13 +1335,22 @@ function SeatIcon({
</>
)}
{canUnblock && (
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Unblock seat"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
<>
<button
onClick={() => handleUnblock(seat)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Release reservation"
>
<Unlock className="h-3 w-3 text-gray-700" />
</button>
<button
onClick={() => handleIssueBooking(seat, coach)}
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
title="Issue booking"
>
<TicketIcon className="h-3 w-3 text-gray-700" />
</button>
</>
)}
{canMaintenance && (
<button

View File

@@ -48,6 +48,10 @@ export const bookingsApi = {
apiClient.post<any>(`/payments/${bookingId}/force-confirm`, data),
smartAssign: (bookingId: string) =>
apiClient.post<any>(`/tickets/smart-assign/${bookingId}`, {}),
// Converts a reserved (blocked) seat into a real booking — STAFF (fee-waived, ticket
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
issueFromReservation: (seatId: string, data: any) =>
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
};
// Passengers API