diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index b14084d43..e0eac4bc1 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -352,12 +352,12 @@ export class BookingsController { } @Post("reservations/:seatId/issue") - @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @PassengerAdmin() @ApiBearerAuth("IAM-auth") @ApiOperation({ - summary: "Issue a booking from a reserved (blocked) seat", + summary: "Issue a booking from a reserved (blocked) seat — admin only", description: - "Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone.", + "Converts an admin-reserved seat into a real booking for one traveler. bookingKind STAFF waives the fee and issues the ticket immediately; bookingKind PASSENGER creates the booking as PENDING_PAYMENT and texts a payment link to the traveler's phone. Restricted to admins (edr_passenger_app:admin) because STAFF issuance waives the fare.", }) @ApiBody({ type: IssueReservationBookingDto }) issueBookingFromReservation( diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index b2c63e885..35d0290ea 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock, RefreshCw } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -211,6 +211,13 @@ export default function SchedulesPage() { }, }); + const recalculateStopsMutation = useMutation({ + mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['schedules'] }); + }, + }); + const deleteScheduleMutation = useMutation({ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 092e26309..9b0a203ab 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -4,6 +4,8 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api'; import { routesApi } from '@/lib/api/routes'; +import { usePermission } from '@/lib/use-permission'; +import { PERMS } from '@/lib/permissions'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; @@ -42,6 +44,10 @@ export default function SeatsPage() { const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); + // Issuing a booking off a reserved seat is admin-only (POST /bookings/reservations/:seatId/issue + // is guarded by @PassengerAdmin) — hide the ticket action for everyone else. + const canIssueBooking = usePermission(PERMS.admin); + const { data: schedulesData } = useQuery({ queryKey: ['schedules'], queryFn: () => schedulesApi.getAll(), @@ -454,6 +460,7 @@ export default function SeatsPage() { handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -551,6 +558,7 @@ export default function SeatsPage() { handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -575,6 +583,7 @@ export default function SeatsPage() { handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} handleIssueBooking={handleIssueBooking} + canIssueBooking={canIssueBooking} hideNumber={true} /> ))} @@ -1268,6 +1277,7 @@ interface SeatIconProps { handleSetMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void; handleIssueBooking: (seat: any, coach: any) => void; + canIssueBooking?: boolean; } function SeatIcon({ @@ -1286,6 +1296,7 @@ function SeatIcon({ handleSetMaintenance, handleClearMaintenance, handleIssueBooking, + canIssueBooking = false, }: SeatIconProps) { const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-'); const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || ''); @@ -1399,13 +1410,15 @@ function SeatIcon({ > - + {canIssueBooking && ( + + )} )} {canMaintenance && (