mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
Adding ticket generation and booking for staff employees logic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user