mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 10:45:44 +00:00
feat: ( permissions ) enforce specific permission keys across API and backoffice
This commit is contained in:
@@ -26,3 +26,30 @@ export const PassengerStaffStrict = (permission: string | string[]) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
export const PassengerAdmin = () => PassengerStaff(PASSENGER_PERMS.admin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A create / edit / domain action: the narrow key, the resource's `:manage`
|
||||||
|
* umbrella, or admin.
|
||||||
|
*
|
||||||
|
* Keeping `:manage` in the array is what makes the fine-grained keys additive —
|
||||||
|
* every position already granted `<res>:manage` keeps working without being
|
||||||
|
* re-granted in IAM. Grant the narrow key *instead of* `:manage` to restrict
|
||||||
|
* someone.
|
||||||
|
*
|
||||||
|
* @PassengerWrite(PASSENGER_PERMS.schedules.create, PASSENGER_PERMS.schedules.manage)
|
||||||
|
*/
|
||||||
|
export const PassengerWrite = (narrow: string, umbrella: string) =>
|
||||||
|
PassengerStaff([narrow, umbrella, PASSENGER_PERMS.admin]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A delete: the narrow `:delete` key or admin. **`:manage` is deliberately not
|
||||||
|
* accepted.**
|
||||||
|
*
|
||||||
|
* Every DELETE in this app was `@PassengerAdmin()` before the fine-grained keys
|
||||||
|
* existed, and most are hard cascading deletes. Letting `:manage` through here
|
||||||
|
* would silently hand deletion to `operationsManager`, `marketingManager` and
|
||||||
|
* every other role holding a `:manage` key — access they do not have today.
|
||||||
|
* So `:manage` means create + edit, never delete.
|
||||||
|
*/
|
||||||
|
export const PassengerDelete = (narrow: string) =>
|
||||||
|
PassengerStaff([narrow, PASSENGER_PERMS.admin]);
|
||||||
|
|||||||
@@ -145,3 +145,31 @@ export function assertPassengerPermission(
|
|||||||
if (hasPassengerPermission(user, permissionKey)) return;
|
if (hasPassengerPermission(user, permissionKey)) return;
|
||||||
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
throw new ForbiddenException(`Missing permission: ${permissionKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Holds at least one of the keys. Same OR semantics as `PassengerPermissionGuard`. */
|
||||||
|
export function hasAnyPassengerPermission(
|
||||||
|
user: MeLikeUser | null | undefined,
|
||||||
|
permissionKeys: string[],
|
||||||
|
): boolean {
|
||||||
|
return permissionKeys.some((key) => hasPassengerPermission(user, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-handler equivalent of `@PassengerStaff([...])`, for actions a decorator
|
||||||
|
* cannot see — where the destructive variant is chosen by a body field rather
|
||||||
|
* than by the route. Cancelling a schedule is the case this exists for:
|
||||||
|
* `PATCH /schedules/:id/status` carries `{ status: 'CANCELLED' }` on the same
|
||||||
|
* route as every routine transition.
|
||||||
|
*
|
||||||
|
* Pass the umbrella and admin keys alongside the narrow one, exactly as a guard
|
||||||
|
* array would, so existing grants keep working.
|
||||||
|
*/
|
||||||
|
export function assertAnyPassengerPermission(
|
||||||
|
user: MeLikeUser | null | undefined,
|
||||||
|
permissionKeys: string[],
|
||||||
|
): void {
|
||||||
|
if (hasAnyPassengerPermission(user, permissionKeys)) return;
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Missing permission. Required one of: ${permissionKeys.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { AgentsService } from './agents.service';
|
import { AgentsService } from './agents.service';
|
||||||
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
|
||||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Agents')
|
@ApiTags('Agents')
|
||||||
@Controller('agents')
|
@Controller('agents')
|
||||||
@@ -26,18 +27,20 @@ export class AgentsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
|
@ApiOperation({ summary: 'Create agent profile linked to an IAM user' })
|
||||||
|
@PassengerWrite(PASSENGER_PERMS.agents.create, PASSENGER_PERMS.agents.manage)
|
||||||
createAgent(@Body() dto: CreateAgentDto) {
|
createAgent(@Body() dto: CreateAgentDto) {
|
||||||
return this.service.createAgent(dto);
|
return this.service.createAgent(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update agent profile' })
|
@ApiOperation({ summary: 'Update agent profile' })
|
||||||
|
@PassengerWrite(PASSENGER_PERMS.agents.edit, PASSENGER_PERMS.agents.manage)
|
||||||
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
|
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
|
||||||
return this.service.updateAgent(id, dto);
|
return this.service.updateAgent(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.agents.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete agent profile' })
|
@ApiOperation({ summary: 'Delete agent profile' })
|
||||||
deleteAgent(@Param('id') id: string) {
|
deleteAgent(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
IssueReservationBookingDto,
|
IssueReservationBookingDto,
|
||||||
} from "./guest-booking.dto";
|
} from "./guest-booking.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
import { PassengerAdmin, PassengerStaff, PassengerStaffStrict } from "../../common/passenger-guards";
|
import { PassengerDelete, PassengerStaff, PassengerStaffStrict, PassengerWrite } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { SeatsService } from "../seats/seats.service";
|
import { SeatsService } from "../seats/seats.service";
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("group")
|
@Post("group")
|
||||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
summary: "Create a group booking — staff bulk/group reservation, one PNR for the whole group",
|
||||||
@@ -417,7 +417,14 @@ If booking creation fails after the seats were already held, every hold involved
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete("reservations/:seatId")
|
@Delete("reservations/:seatId")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
// Releasing a held seat is a cancel, not a booking delete — `bookings:cancel` is the
|
||||||
|
// narrow key for it; the two `:manage` keys stay so today's holders are unaffected.
|
||||||
|
@PassengerStaff([
|
||||||
|
PASSENGER_PERMS.bookings.cancel,
|
||||||
|
PASSENGER_PERMS.seats.manage,
|
||||||
|
PASSENGER_PERMS.bookings.manage,
|
||||||
|
PASSENGER_PERMS.admin,
|
||||||
|
])
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
||||||
@@ -703,7 +710,7 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.bookings.delete)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
description: "Permanently deletes a booking record",
|
description: "Permanently deletes a booking record",
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { CurrencyService } from './currency.service';
|
import { CurrencyService } from './currency.service';
|
||||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
class CreateRateDto {
|
class CreateRateDto {
|
||||||
@IsEnum(Currency) fromCurrency: Currency;
|
@IsEnum(Currency) fromCurrency: Currency;
|
||||||
@@ -30,7 +31,7 @@ export class CurrencyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.currencies.create, PASSENGER_PERMS.currencies.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create exchange rate' })
|
@ApiOperation({ summary: 'Create exchange rate' })
|
||||||
create(@Body() dto: CreateRateDto) {
|
create(@Body() dto: CreateRateDto) {
|
||||||
@@ -38,7 +39,7 @@ export class CurrencyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.currencies.edit, PASSENGER_PERMS.currencies.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update exchange rate by ID' })
|
@ApiOperation({ summary: 'Update exchange rate by ID' })
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
|
||||||
@@ -46,7 +47,7 @@ export class CurrencyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.currencies.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete exchange rate by ID' })
|
@ApiOperation({ summary: 'Delete exchange rate by ID' })
|
||||||
delete(@Param('id') id: string) {
|
delete(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -9,7 +9,20 @@ import {
|
|||||||
ConfirmExcessOtpDto,
|
ConfirmExcessOtpDto,
|
||||||
} from './excess-baggage.dto';
|
} from './excess-baggage.dto';
|
||||||
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
import { PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||||
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logging or re-sending a luggage charge bills a passenger and texts them a payment
|
||||||
|
* link, so it is its own grant. `bookings:manage` stays in the array as the umbrella —
|
||||||
|
* baggage hangs off a booking, and that is the key the backoffice already assumed.
|
||||||
|
*
|
||||||
|
* These two routes previously required no permission at all: the class-level
|
||||||
|
* `IamJwtGuard` is only authentication, so any signed-in account — a portal customer
|
||||||
|
* included — could raise a charge.
|
||||||
|
*/
|
||||||
|
const CanCharge = () =>
|
||||||
|
PassengerStaff([PASSENGER_PERMS.excessBaggage.charge, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]);
|
||||||
|
|
||||||
class UpsertBaggageAllowanceDto {
|
class UpsertBaggageAllowanceDto {
|
||||||
@IsString() seatClassId: string;
|
@IsString() seatClassId: string;
|
||||||
@@ -27,6 +40,7 @@ export class ExcessBaggageAgentController {
|
|||||||
constructor(private service: ExcessBaggageService) {}
|
constructor(private service: ExcessBaggageService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@CanCharge()
|
||||||
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
|
||||||
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
|
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
|
||||||
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
|
dto.agentId = req.user?.id ?? req.user?.sub ?? '';
|
||||||
@@ -86,6 +100,7 @@ export class ExcessBaggageAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/resend')
|
@Post(':id/resend')
|
||||||
|
@CanCharge()
|
||||||
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
|
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
|
||||||
resendLink(@Param('id') id: string) {
|
resendLink(@Param('id') id: string) {
|
||||||
return this.service.resendLink(id);
|
return this.service.resendLink(id);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
|
|||||||
import { FleetService } from './fleet.service';
|
import { FleetService } from './fleet.service';
|
||||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Fleet')
|
@ApiTags('Fleet')
|
||||||
@@ -22,7 +22,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('coach-types')
|
@Post('coach-types')
|
||||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({ summary: 'Create a coach type' })
|
@ApiOperation({ summary: 'Create a coach type' })
|
||||||
@ApiBody({ type: CreateCoachTypeDto })
|
@ApiBody({ type: CreateCoachTypeDto })
|
||||||
@ApiResponse({ status: 201, description: 'Coach type created' })
|
@ApiResponse({ status: 201, description: 'Coach type created' })
|
||||||
@@ -31,7 +31,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('coach-types/:id')
|
@Patch('coach-types/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({ summary: 'Update a coach type' })
|
@ApiOperation({ summary: 'Update a coach type' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||||
@ApiBody({ type: UpdateCoachTypeDto })
|
@ApiBody({ type: UpdateCoachTypeDto })
|
||||||
@@ -42,7 +42,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('coach-types/:id')
|
@Delete('coach-types/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a coach type' })
|
@ApiOperation({ summary: 'Delete a coach type' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
|
||||||
@@ -62,7 +62,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('classes')
|
@Post('classes')
|
||||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
|
||||||
@ApiOperation({ summary: 'Create a class' })
|
@ApiOperation({ summary: 'Create a class' })
|
||||||
@ApiBody({ type: CreateClassDto })
|
@ApiBody({ type: CreateClassDto })
|
||||||
@ApiResponse({ status: 201, description: 'Class created' })
|
@ApiResponse({ status: 201, description: 'Class created' })
|
||||||
@@ -71,7 +71,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('classes/:id')
|
@Patch('classes/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
|
||||||
@ApiOperation({ summary: 'Update a class' })
|
@ApiOperation({ summary: 'Update a class' })
|
||||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
@ApiBody({ type: UpdateClassDto })
|
@ApiBody({ type: UpdateClassDto })
|
||||||
@@ -82,7 +82,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('classes/:id')
|
@Delete('classes/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.classes.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a class' })
|
@ApiOperation({ summary: 'Delete a class' })
|
||||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
@@ -103,7 +103,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('seat-classes')
|
@Post('seat-classes')
|
||||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.classes.create, PASSENGER_PERMS.classes.manage)
|
||||||
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
|
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
|
||||||
@ApiBody({ type: CreateClassDto })
|
@ApiBody({ type: CreateClassDto })
|
||||||
@ApiResponse({ status: 201, description: 'Class created' })
|
@ApiResponse({ status: 201, description: 'Class created' })
|
||||||
@@ -112,7 +112,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('seat-classes/:id')
|
@Patch('seat-classes/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.classes.edit, PASSENGER_PERMS.classes.manage)
|
||||||
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
|
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
|
||||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
@ApiBody({ type: UpdateClassDto })
|
@ApiBody({ type: UpdateClassDto })
|
||||||
@@ -123,7 +123,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('seat-classes/:id')
|
@Delete('seat-classes/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.classes.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
@ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' })
|
||||||
@ApiParam({ name: 'id', description: 'Class UUID' })
|
@ApiParam({ name: 'id', description: 'Class UUID' })
|
||||||
@@ -143,7 +143,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('trains')
|
@Post('trains')
|
||||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.trains.create, PASSENGER_PERMS.trains.manage)
|
||||||
@ApiOperation({ summary: 'Create a train service' })
|
@ApiOperation({ summary: 'Create a train service' })
|
||||||
@ApiBody({ type: CreateTrainDto })
|
@ApiBody({ type: CreateTrainDto })
|
||||||
@ApiResponse({ status: 201, description: 'Train created' })
|
@ApiResponse({ status: 201, description: 'Train created' })
|
||||||
@@ -152,7 +152,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('trains/:id')
|
@Patch('trains/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
|
||||||
@ApiOperation({ summary: 'Update a train service' })
|
@ApiOperation({ summary: 'Update a train service' })
|
||||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||||
@ApiBody({ type: CreateTrainDto })
|
@ApiBody({ type: CreateTrainDto })
|
||||||
@@ -163,7 +163,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('trains/:id')
|
@Delete('trains/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.trains.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a train service' })
|
@ApiOperation({ summary: 'Delete a train service' })
|
||||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||||
@@ -175,7 +175,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('trains/:id/restore')
|
@Patch('trains/:id/restore')
|
||||||
@PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.trains.edit, PASSENGER_PERMS.trains.manage)
|
||||||
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
|
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
|
||||||
@ApiParam({ name: 'id', description: 'Train UUID' })
|
@ApiParam({ name: 'id', description: 'Train UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Train restored' })
|
@ApiResponse({ status: 200, description: 'Train restored' })
|
||||||
@@ -229,6 +229,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('coaches/utilization')
|
@Get('coaches/utilization')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.reports.coachUtilization.view, PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||||
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
|
@ApiOperation({ summary: 'Coach utilization report — seats, bookings, and assignment history per coach for a selected schedule' })
|
||||||
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
@ApiQuery({ name: 'scheduleId', required: false, description: 'Optional schedule UUID to scope the utilization report to that schedule.' })
|
||||||
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
@ApiResponse({ status: 200, description: 'Coach utilization data' })
|
||||||
@@ -279,7 +280,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('coaches')
|
@Post('coaches')
|
||||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.coaches.create, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
|
||||||
@ApiBody({ type: CreateCoachDto })
|
@ApiBody({ type: CreateCoachDto })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -305,7 +306,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('coaches/:id')
|
@Patch('coaches/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({ summary: 'Update coach properties' })
|
@ApiOperation({ summary: 'Update coach properties' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@ApiBody({ type: UpdateCoachDto })
|
@ApiBody({ type: UpdateCoachDto })
|
||||||
@@ -332,7 +333,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('coaches/:id')
|
@Delete('coaches/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.coaches.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a coach' })
|
@ApiOperation({ summary: 'Delete a coach' })
|
||||||
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
@ApiParam({ name: 'id', description: 'Coach UUID' })
|
||||||
@@ -344,7 +345,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('assignments')
|
@Post('assignments')
|
||||||
@PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({ summary: 'Assign a coach to a schedule' })
|
@ApiOperation({ summary: 'Assign a coach to a schedule' })
|
||||||
@ApiBody({ type: AssignCoachDto })
|
@ApiBody({ type: AssignCoachDto })
|
||||||
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
@ApiResponse({ status: 201, description: 'Coach assigned' })
|
||||||
@@ -354,7 +355,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('assignments/:id')
|
@Delete('assignments/:id')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||||
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
@ApiParam({ name: 'id', description: 'Assignment UUID' })
|
||||||
@@ -365,6 +366,7 @@ export class FleetController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('seatmap/generate')
|
@Post('seatmap/generate')
|
||||||
|
@PassengerWrite(PASSENGER_PERMS.coaches.edit, PASSENGER_PERMS.coaches.manage)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
summary: 'Preview bed seat map — ECONOMY_BED or VIP_BED',
|
||||||
description: `Generates a structured seat map for bed coaches without persisting anything.
|
description: `Generates a structured seat map for bed coaches without persisting anything.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, Patch, Param, Body, Query, Logger } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||||
import { PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Fraud Detection')
|
@ApiTags('Fraud Detection')
|
||||||
@@ -41,7 +41,7 @@ export class FraudController {
|
|||||||
* Create or update fraud rule
|
* Create or update fraud rule
|
||||||
*/
|
*/
|
||||||
@Post('rules')
|
@Post('rules')
|
||||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.fraud.create, PASSENGER_PERMS.fraud.manage)
|
||||||
@ApiOperation({ summary: 'Create or update fraud rule' })
|
@ApiOperation({ summary: 'Create or update fraud rule' })
|
||||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||||
@@ -52,7 +52,7 @@ export class FraudController {
|
|||||||
* Acknowledge a fraud alert
|
* Acknowledge a fraud alert
|
||||||
*/
|
*/
|
||||||
@Patch('alerts/:id/acknowledge')
|
@Patch('alerts/:id/acknowledge')
|
||||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
|
||||||
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
|
@ApiOperation({ summary: 'Acknowledge a fraud alert' })
|
||||||
async acknowledgeAlert(@Param('id') id: string) {
|
async acknowledgeAlert(@Param('id') id: string) {
|
||||||
const alert = await this.fraudService.acknowledgeAlert(id);
|
const alert = await this.fraudService.acknowledgeAlert(id);
|
||||||
@@ -63,7 +63,7 @@ export class FraudController {
|
|||||||
* Block user via userId
|
* Block user via userId
|
||||||
*/
|
*/
|
||||||
@Post('users/:userId/block')
|
@Post('users/:userId/block')
|
||||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
|
||||||
@ApiOperation({ summary: 'Block user by userId' })
|
@ApiOperation({ summary: 'Block user by userId' })
|
||||||
async blockUserById(
|
async blockUserById(
|
||||||
@Param('userId') userId: string,
|
@Param('userId') userId: string,
|
||||||
@@ -77,7 +77,7 @@ export class FraudController {
|
|||||||
* Block user temporarily
|
* Block user temporarily
|
||||||
*/
|
*/
|
||||||
@Post('actions/block')
|
@Post('actions/block')
|
||||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
|
||||||
@ApiOperation({ summary: 'Block user temporarily' })
|
@ApiOperation({ summary: 'Block user temporarily' })
|
||||||
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
|
async blockUser(@Body() body: { iamUserId: string; durationMinutes: number }) {
|
||||||
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
|
await this.fraudService.blockUserTemporarily(body.iamUserId, body.durationMinutes);
|
||||||
@@ -88,7 +88,7 @@ export class FraudController {
|
|||||||
* Unblock user
|
* Unblock user
|
||||||
*/
|
*/
|
||||||
@Post('actions/unblock')
|
@Post('actions/unblock')
|
||||||
@PassengerStaff([PASSENGER_PERMS.fraud.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.fraud.edit, PASSENGER_PERMS.fraud.manage)
|
||||||
@ApiOperation({ summary: 'Unblock user' })
|
@ApiOperation({ summary: 'Unblock user' })
|
||||||
async unblockUser(@Body() body: { iamUserId: string }) {
|
async unblockUser(@Body() body: { iamUserId: string }) {
|
||||||
await this.fraudService.unblockUser(body.iamUserId);
|
await this.fraudService.unblockUser(body.iamUserId);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export class NotificationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('test')
|
@Post('test')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.notifications.send, PASSENGER_PERMS.admin])
|
||||||
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
@ApiOperation({ summary: 'Test notification delivery (Admin only)' })
|
||||||
async testNotification(@Body() dto: TestNotificationDto) {
|
async testNotification(@Body() dto: TestNotificationDto) {
|
||||||
return this.service.send(
|
return this.service.send(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt
|
|||||||
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
import { PACKAGE_IMAGE_FIELD, packageImageMulterOptions } from './package-image-upload.options';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Packages')
|
@ApiTags('Packages')
|
||||||
@@ -36,7 +36,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('inquiries/:id/status')
|
@Patch('inquiries/:id/status')
|
||||||
@PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.inquiries.edit, PASSENGER_PERMS.inquiries.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
|
||||||
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
|
||||||
@@ -44,7 +44,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('inquiries/:id')
|
@Delete('inquiries/:id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.inquiries.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
@ApiOperation({ summary: 'Delete inquiry (backoffice)' })
|
||||||
deleteInquiry(@Param('id') id: string) {
|
deleteInquiry(@Param('id') id: string) {
|
||||||
@@ -126,7 +126,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.create, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create package (admin)' })
|
@ApiOperation({ summary: 'Create package (admin)' })
|
||||||
create(@Body() dto: CreatePackageDto) {
|
create(@Body() dto: CreatePackageDto) {
|
||||||
@@ -134,7 +134,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update package (admin)' })
|
@ApiOperation({ summary: 'Update package (admin)' })
|
||||||
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
|
update(@Param('id') id: string, @Body() dto: Partial<CreatePackageDto>) {
|
||||||
@@ -142,7 +142,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.packages.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete package (admin)' })
|
@ApiOperation({ summary: 'Delete package (admin)' })
|
||||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
|
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' })
|
||||||
@@ -151,7 +151,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/image')
|
@Post(':id/image')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
@UseInterceptors(FileInterceptor(PACKAGE_IMAGE_FIELD, packageImageMulterOptions))
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@@ -166,7 +166,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/image')
|
@Delete(':id/image')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
@ApiOperation({ summary: 'Remove a package image without deleting the package (admin)' })
|
||||||
removeImage(@Param('id') id: string) {
|
removeImage(@Param('id') id: string) {
|
||||||
@@ -174,7 +174,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/activate')
|
@Patch(':id/activate')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Activate package (admin)' })
|
@ApiOperation({ summary: 'Activate package (admin)' })
|
||||||
activate(@Param('id') id: string) {
|
activate(@Param('id') id: string) {
|
||||||
@@ -182,7 +182,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/deactivate')
|
@Patch(':id/deactivate')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.publish, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Deactivate package (admin)' })
|
@ApiOperation({ summary: 'Deactivate package (admin)' })
|
||||||
deactivate(@Param('id') id: string) {
|
deactivate(@Param('id') id: string) {
|
||||||
@@ -190,7 +190,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/tiers')
|
@Post(':id/tiers')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Add price tier to package (admin)' })
|
@ApiOperation({ summary: 'Add price tier to package (admin)' })
|
||||||
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
|
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
|
||||||
@@ -198,7 +198,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('tiers/:tierId')
|
@Patch('tiers/:tierId')
|
||||||
@PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update price tier (admin)' })
|
@ApiOperation({ summary: 'Update price tier (admin)' })
|
||||||
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
|
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
|
||||||
@@ -206,7 +206,7 @@ export class PackagesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('tiers/:tierId')
|
@Delete('tiers/:tierId')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.packages.edit, PASSENGER_PERMS.packages.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete price tier (admin)' })
|
@ApiOperation({ summary: 'Delete price tier (admin)' })
|
||||||
deleteTier(@Param('tierId') tierId: string) {
|
deleteTier(@Param('tierId') tierId: string) {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import {
|
|||||||
RegisterPassengerDto,
|
RegisterPassengerDto,
|
||||||
} from "./passengers.dto";
|
} from "./passengers.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
import { PassengerAdmin } from "../../common/passenger-guards";
|
import { PassengerDelete } from "../../common/passenger-guards";
|
||||||
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { VerifaydaService } from "../verifayda/verifayda.service";
|
import { VerifaydaService } from "../verifayda/verifayda.service";
|
||||||
import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
|
import { OptionalJwtGuard } from "../verifayda/optional-jwt.guard";
|
||||||
import { PrismaService } from "../../common/prisma.service";
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
@@ -566,7 +567,7 @@ Returns saved passenger details with generated IDs and confirmation.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.passengers.delete)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Delete passenger (admin only)",
|
summary: "Delete passenger (admin only)",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
ForceConfirmDto,
|
ForceConfirmDto,
|
||||||
ConfirmOtpDto,
|
ConfirmOtpDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { PassengerStaff } from "../../common/passenger-guards";
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
import { resolveActingUser } from "../../common/acting-user";
|
import { resolveActingUser } from "../../common/acting-user";
|
||||||
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util";
|
||||||
@@ -85,7 +85,7 @@ export class PaymentsController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@PassengerStaff([PASSENGER_PERMS.admin])
|
@PassengerDelete(PASSENGER_PERMS.payments.delete)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
|
@ApiOperation({ summary: "Delete a payment intent record (admin only)" })
|
||||||
deletePayment(@Param("id") id: string) {
|
deletePayment(@Param("id") id: string) {
|
||||||
@@ -252,6 +252,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
@Post("methods")
|
@Post("methods")
|
||||||
@PassengerStaff([
|
@PassengerStaff([
|
||||||
|
PASSENGER_PERMS.paymentMethods.create,
|
||||||
PASSENGER_PERMS.paymentMethods.manage,
|
PASSENGER_PERMS.paymentMethods.manage,
|
||||||
PASSENGER_PERMS.payments.manageMethods,
|
PASSENGER_PERMS.payments.manageMethods,
|
||||||
PASSENGER_PERMS.admin,
|
PASSENGER_PERMS.admin,
|
||||||
@@ -266,6 +267,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
@Patch("methods/:id")
|
@Patch("methods/:id")
|
||||||
@PassengerStaff([
|
@PassengerStaff([
|
||||||
|
PASSENGER_PERMS.paymentMethods.edit,
|
||||||
PASSENGER_PERMS.paymentMethods.manage,
|
PASSENGER_PERMS.paymentMethods.manage,
|
||||||
PASSENGER_PERMS.payments.manageMethods,
|
PASSENGER_PERMS.payments.manageMethods,
|
||||||
PASSENGER_PERMS.admin,
|
PASSENGER_PERMS.admin,
|
||||||
@@ -394,7 +396,7 @@ export class PaymentsController {
|
|||||||
// ── Supplementary Charges ──────────────────────────────────────────────────
|
// ── Supplementary Charges ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post('supplementary')
|
@Post('supplementary')
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
@PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
|
@ApiOperation({ summary: 'Raise a supplementary charge for an underpayment (staff only)' })
|
||||||
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
|
createSupplementaryCharge(@Body() dto: CreateSupplementaryChargeDto, @Req() req: any) {
|
||||||
@@ -504,7 +506,7 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('supplementary/:id/mark-paid')
|
@Post('supplementary/:id/mark-paid')
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
|
@ApiOperation({ summary: 'Manually mark a supplementary charge as paid (staff only)' })
|
||||||
markSupplementaryPaid(
|
markSupplementaryPaid(
|
||||||
@@ -515,7 +517,7 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('supplementary/:id/waive')
|
@Post('supplementary/:id/waive')
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.payments.edit, PASSENGER_PERMS.payments.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
|
@ApiOperation({ summary: 'Waive a supplementary charge (staff only)' })
|
||||||
waiveSupplementaryCharge(
|
waiveSupplementaryCharge(
|
||||||
@@ -528,7 +530,7 @@ export class PaymentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('supplementary/:id/resend')
|
@Post('supplementary/:id/resend')
|
||||||
@PassengerStaff([PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
@PassengerStaff([PASSENGER_PERMS.payments.supplementary, PASSENGER_PERMS.payments.create, PASSENGER_PERMS.payments.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
|
@ApiOperation({ summary: 'Resend payment link for a supplementary charge (staff only)' })
|
||||||
resendSupplementaryLink(@Param('id') id: string) {
|
resendSupplementaryLink(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -12,26 +12,60 @@ import { BlockedSeatsRevenueLossQueryDto, FinanceSummaryQueryDto, GenerateReport
|
|||||||
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";
|
||||||
|
|
||||||
|
const R = PASSENGER_PERMS.reports;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One report's guard: its own key, the `reports:view` umbrella, or admin.
|
||||||
|
*
|
||||||
|
* The umbrella is kept in every array so the `finance`, `financeManager` and
|
||||||
|
* `director` presets — which hold `reports:view` — keep seeing every report.
|
||||||
|
* Granting only a per-report key hands out that report and nothing else.
|
||||||
|
*
|
||||||
|
* This is deliberately NOT a class-level decorator. Nest requires controller-
|
||||||
|
* level AND route-level guards to both pass, so a class-level
|
||||||
|
* `[reports.view, admin]` plus a per-route key would be an AND and would lock
|
||||||
|
* out everyone holding only `reports:view`.
|
||||||
|
*/
|
||||||
|
const Report = (key: string) => PassengerStaff([key, R.view, PASSENGER_PERMS.admin]);
|
||||||
|
|
||||||
|
/** The schedule picker is shared by five reports, so any report key opens it. */
|
||||||
|
const AnyReport = () =>
|
||||||
|
PassengerStaff([
|
||||||
|
R.overall.view,
|
||||||
|
R.finance.view,
|
||||||
|
R.coachUtilization.view,
|
||||||
|
R.seatStatus.view,
|
||||||
|
R.blockedSeats.view,
|
||||||
|
R.passengers.view,
|
||||||
|
R.boarding.view,
|
||||||
|
R.payments.view,
|
||||||
|
R.catalog.view,
|
||||||
|
R.view,
|
||||||
|
PASSENGER_PERMS.admin,
|
||||||
|
]);
|
||||||
|
|
||||||
@ApiTags("Reports")
|
@ApiTags("Reports")
|
||||||
@Controller("reports")
|
@Controller("reports")
|
||||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private service: ReportsService) {}
|
constructor(private service: ReportsService) {}
|
||||||
|
|
||||||
@Post("generate")
|
@Post("generate")
|
||||||
|
@Report(R.catalog.view)
|
||||||
@ApiOperation({ summary: "Generate operational report" })
|
@ApiOperation({ summary: "Generate operational report" })
|
||||||
generateReport(@Body() dto: GenerateReportDto) {
|
generateReport(@Body() dto: GenerateReportDto) {
|
||||||
return this.service.generateReport(dto);
|
return this.service.generateReport(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('schedules')
|
@Get('schedules')
|
||||||
|
@AnyReport()
|
||||||
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
@ApiOperation({ summary: 'List schedules for the passengers report picker' })
|
||||||
listSchedulesForPicker(@Query('all') all?: string) {
|
listSchedulesForPicker(@Query('all') all?: string) {
|
||||||
return this.service.listSchedulesForPicker(all === 'true');
|
return this.service.listSchedulesForPicker(all === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("passengers/list")
|
@Get("passengers/list")
|
||||||
|
@Report(R.passengers.view)
|
||||||
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
||||||
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
||||||
return this.service.getPassengerList(scheduleId);
|
return this.service.getPassengerList(scheduleId);
|
||||||
@@ -39,6 +73,7 @@ export class ReportsController {
|
|||||||
|
|
||||||
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
||||||
@Get("passengers/overview")
|
@Get("passengers/overview")
|
||||||
|
@Report(R.passengers.view)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Fleet-wide passenger mix across a departure window",
|
summary: "Fleet-wide passenger mix across a departure window",
|
||||||
description:
|
description:
|
||||||
@@ -56,12 +91,14 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("passengers")
|
@Get("passengers")
|
||||||
|
@Report(R.passengers.view)
|
||||||
@ApiOperation({ summary: "Passengers report for a specific schedule" })
|
@ApiOperation({ summary: "Passengers report for a specific schedule" })
|
||||||
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
|
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
|
||||||
return this.service.getOccupancyBySchedule(scheduleId);
|
return this.service.getOccupancyBySchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("payment-discrepancy")
|
@Get("payment-discrepancy")
|
||||||
|
@Report(R.payments.view)
|
||||||
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||||
getPaymentDiscrepancy(
|
getPaymentDiscrepancy(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@@ -73,6 +110,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("seat-status")
|
@Get("seat-status")
|
||||||
|
@Report(R.seatStatus.view)
|
||||||
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
@ApiOperation({ summary: "Seat status breakdown for a schedule (paid, unpaid, expired holds, blocked)" })
|
||||||
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
getSeatStatusReport(@Query('scheduleId') scheduleId: string) {
|
||||||
return this.service.getSeatStatusReport(scheduleId);
|
return this.service.getSeatStatusReport(scheduleId);
|
||||||
@@ -80,6 +118,7 @@ export class ReportsController {
|
|||||||
|
|
||||||
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
// Two segments, so `@Get(":reportId")` below cannot shadow it whatever the order.
|
||||||
@Get("seat-status/overview")
|
@Get("seat-status/overview")
|
||||||
|
@Report(R.seatStatus.view)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Fleet-wide seat status across a departure window",
|
summary: "Fleet-wide seat status across a departure window",
|
||||||
description:
|
description:
|
||||||
@@ -96,18 +135,21 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("boarding")
|
@Get("boarding")
|
||||||
|
@Report(R.boarding.view)
|
||||||
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
@ApiOperation({ summary: "Boarding report for a schedule — boarded vs not-boarded passengers" })
|
||||||
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
getBoardingReport(@Query('scheduleId') scheduleId: string) {
|
||||||
return this.service.getBoardingReport(scheduleId);
|
return this.service.getBoardingReport(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("payments")
|
@Get("payments")
|
||||||
|
@Report(R.payments.view)
|
||||||
@ApiOperation({ summary: "Payments collected for a schedule" })
|
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||||
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||||
return this.service.getPaymentsReport(scheduleId);
|
return this.service.getPaymentsReport(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("payments/discrepancy")
|
@Get("payments/discrepancy")
|
||||||
|
@Report(R.payments.view)
|
||||||
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||||
getPaymentDiscrepancyBySchedule(
|
getPaymentDiscrepancyBySchedule(
|
||||||
@Query('scheduleId') scheduleId: string,
|
@Query('scheduleId') scheduleId: string,
|
||||||
@@ -121,6 +163,7 @@ export class ReportsController {
|
|||||||
// ── Finance Summary ──────────────────────────────────────────────────────
|
// ── Finance Summary ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("finance")
|
@Get("finance")
|
||||||
|
@Report(R.finance.view)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
|
summary: "Finance summary — revenue by period, origin/destination segment, payment method, and currency",
|
||||||
description:
|
description:
|
||||||
@@ -138,6 +181,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("finance/export")
|
@Get("finance/export")
|
||||||
|
@Report(R.finance.export)
|
||||||
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
@ApiOperation({ summary: "Finance summary as CSV — one row per period + route + payment method" })
|
||||||
@ApiProduces("text/csv")
|
@ApiProduces("text/csv")
|
||||||
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
@ApiOkResponse({ description: "CSV export", schema: { type: "string" } })
|
||||||
@@ -154,6 +198,7 @@ export class ReportsController {
|
|||||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||||
|
|
||||||
@Get("blocked-seats-revenue-loss")
|
@Get("blocked-seats-revenue-loss")
|
||||||
|
@Report(R.blockedSeats.view)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Potential revenue lost to blocked seats, per schedule",
|
summary: "Potential revenue lost to blocked seats, per schedule",
|
||||||
description:
|
description:
|
||||||
@@ -176,6 +221,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("blocked-seats-revenue-loss/export")
|
@Get("blocked-seats-revenue-loss/export")
|
||||||
|
@Report(R.blockedSeats.export)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Blocked-seat revenue loss as CSV",
|
summary: "Blocked-seat revenue loss as CSV",
|
||||||
description:
|
description:
|
||||||
@@ -200,12 +246,14 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(":reportId")
|
@Get(":reportId")
|
||||||
|
@Report(R.catalog.view)
|
||||||
@ApiOperation({ summary: "Get report by ID" })
|
@ApiOperation({ summary: "Get report by ID" })
|
||||||
getReport(@Param("reportId") reportId: string) {
|
getReport(@Param("reportId") reportId: string) {
|
||||||
return this.service.getReport(reportId);
|
return this.service.getReport(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@Report(R.catalog.view)
|
||||||
@ApiOperation({ summary: "List reports" })
|
@ApiOperation({ summary: "List reports" })
|
||||||
listReports(@Query("type") type?: string) {
|
listReports(@Query("type") type?: string) {
|
||||||
return this.service.listReports(type);
|
return this.service.listReports(type);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
import { RescheduleService } from './reschedule.service';
|
import { RescheduleService } from './reschedule.service';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,7 @@ export class RescheduleController {
|
|||||||
constructor(private service: RescheduleService) {}
|
constructor(private service: RescheduleService) {}
|
||||||
|
|
||||||
@Get('reschedule/policies')
|
@Get('reschedule/policies')
|
||||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
@PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
|
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
|
||||||
listPolicies() {
|
listPolicies() {
|
||||||
@@ -25,7 +25,7 @@ export class RescheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('reschedule/policies/available-coach-types')
|
@Get('reschedule/policies/available-coach-types')
|
||||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
@PassengerStaff([PASSENGER_PERMS.reschedulePolicies.view, PASSENGER_PERMS.reschedulePolicies.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
|
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
|
||||||
listUnconfiguredCoachTypes() {
|
listUnconfiguredCoachTypes() {
|
||||||
@@ -33,7 +33,7 @@ export class RescheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('reschedule/policies')
|
@Post('reschedule/policies')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.create, PASSENGER_PERMS.reschedulePolicies.manage)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
|
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
|
||||||
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
|
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
|
||||||
@@ -41,7 +41,7 @@ export class RescheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('reschedule/policies/:coachTypeId')
|
@Patch('reschedule/policies/:coachTypeId')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.reschedulePolicies.edit, PASSENGER_PERMS.reschedulePolicies.manage)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
|
@ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' })
|
||||||
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
|
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) {
|
||||||
@@ -49,7 +49,7 @@ export class RescheduleController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('reschedule/policies/:coachTypeId')
|
@Delete('reschedule/policies/:coachTypeId')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.reschedulePolicies.delete)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
|
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
|
||||||
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
|
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Routes')
|
@ApiTags('Routes')
|
||||||
@@ -13,7 +13,7 @@ export class RoutesController {
|
|||||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.routes.create, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Create a reusable route with its ordered stops',
|
summary: 'Create a reusable route with its ordered stops',
|
||||||
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
|
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
|
||||||
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
|
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
|
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' })
|
||||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Route updated' })
|
@ApiResponse({ status: 200, description: 'Route updated' })
|
||||||
@@ -49,7 +49,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.routes.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a route' })
|
@ApiOperation({ summary: 'Delete a route' })
|
||||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||||
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||||
|
|
||||||
@Post(':id/stops')
|
@Post(':id/stops')
|
||||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Add a stop to an existing route' })
|
@ApiOperation({ summary: 'Add a stop to an existing route' })
|
||||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||||
@ApiResponse({ status: 201, description: 'Stop added' })
|
@ApiResponse({ status: 201, description: 'Stop added' })
|
||||||
@@ -77,7 +77,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
|
addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); }
|
||||||
|
|
||||||
@Delete(':id/stops/:sequence')
|
@Delete(':id/stops/:sequence')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
@ApiOperation({ summary: 'Remove a stop from a route by sequence number' })
|
||||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||||
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
|
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
|
||||||
|
|
||||||
@Put(':id/coaches')
|
@Put(':id/coaches')
|
||||||
@PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Set the default coach lineup for this route',
|
summary: 'Set the default coach lineup for this route',
|
||||||
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
|
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
|
||||||
@@ -122,7 +122,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/coaches')
|
@Delete(':id/coaches')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.routes.edit, PASSENGER_PERMS.routes.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
@ApiOperation({ summary: 'Clear the default coach lineup for this route' })
|
||||||
@ApiParam({ name: 'id', description: 'Route UUID' })
|
@ApiParam({ name: 'id', description: 'Route UUID' })
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, ParseIntPipe } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
|
import { assertAnyPassengerPermission } from '../../common/passenger-permission.util';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
|
const P = PASSENGER_PERMS;
|
||||||
|
const S = PASSENGER_PERMS.schedules;
|
||||||
|
const F = PASSENGER_PERMS.scheduleFares;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fare rules live under /schedules but are a separate grant: editing a timetable
|
||||||
|
* and changing a price are different jobs. `schedules.manage` stays in the array
|
||||||
|
* so whoever edits fares today is not locked out the day this ships.
|
||||||
|
*/
|
||||||
|
const FareWrite = (narrow: string) =>
|
||||||
|
PassengerStaff([narrow, F.manage, S.manage, P.admin]);
|
||||||
|
const FareDelete = () => PassengerStaff([F.delete, F.manage, S.manage, P.admin]);
|
||||||
|
|
||||||
@ApiTags('Schedule')
|
@ApiTags('Schedule')
|
||||||
@Controller('schedules')
|
@Controller('schedules')
|
||||||
export class SchedulesController {
|
export class SchedulesController {
|
||||||
constructor(private service: SchedulesService) {}
|
constructor(private service: SchedulesService) {}
|
||||||
|
|
||||||
@Post('bulk-generate')
|
@Post('bulk-generate')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
||||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||||
return this.service.bulkGenerateSchedules(dto);
|
return this.service.bulkGenerateSchedules(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.create, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
||||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||||
|
|
||||||
@@ -42,13 +56,13 @@ export class SchedulesController {
|
|||||||
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
|
||||||
|
|
||||||
@Post('fares')
|
@Post('fares')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
|
||||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||||
|
|
||||||
@Patch('fares/:id')
|
@Patch('fares/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a fare rule' })
|
@ApiOperation({ summary: 'Update a fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||||
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||||
@@ -57,7 +71,7 @@ export class SchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('fares/:id')
|
@Delete('fares/:id')
|
||||||
@PassengerAdmin()
|
@FareDelete()
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a fare rule' })
|
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||||
@@ -65,13 +79,13 @@ export class SchedulesController {
|
|||||||
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||||
|
|
||||||
@Post('segment-fares')
|
@Post('segment-fares')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||||
|
|
||||||
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
|
// Static sub-path MUST come before routes/:routeId/* to avoid :routeId swallowing 'fare-rules'
|
||||||
@Delete('routes/fare-rules/:id')
|
@Delete('routes/fare-rules/:id')
|
||||||
@PassengerAdmin()
|
@FareDelete()
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a route-level fare override' })
|
@ApiOperation({ summary: 'Delete a route-level fare override' })
|
||||||
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
||||||
@@ -80,7 +94,7 @@ export class SchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('routes/fare-rules/:id')
|
@Patch('routes/fare-rules/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a route-level fare override' })
|
@ApiOperation({ summary: 'Update a route-level fare override' })
|
||||||
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'RouteFareRule UUID' })
|
||||||
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
|
updateRouteFareRule(@Param('id') id: string, @Body() dto: any) {
|
||||||
@@ -96,7 +110,7 @@ export class SchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('routes/:routeId/fare-rules')
|
@Post('routes/:routeId/fare-rules')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.create) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create a route-level fare override' })
|
@ApiOperation({ summary: 'Create a route-level fare override' })
|
||||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||||
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
|
createRouteFareRule(@Param('routeId') routeId: string, @Body() dto: any) {
|
||||||
@@ -110,13 +124,13 @@ export class SchedulesController {
|
|||||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||||
|
|
||||||
@Patch('segment-fares/:id')
|
@Patch('segment-fares/:id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||||
|
|
||||||
@Delete('segment-fares/:id')
|
@Delete('segment-fares/:id')
|
||||||
@PassengerAdmin()
|
@FareDelete()
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||||
@@ -131,7 +145,7 @@ export class SchedulesController {
|
|||||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||||
@@ -139,15 +153,33 @@ export class SchedulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update schedule status' })
|
@ApiOperation({
|
||||||
|
summary: 'Update schedule status',
|
||||||
|
description:
|
||||||
|
'Routine transitions need `schedules:edit`. Moving a schedule to CANCELLED additionally ' +
|
||||||
|
'needs `schedules:cancel` — cancelling strands every booked passenger, so it is a separate ' +
|
||||||
|
'grant from editing a timetable.',
|
||||||
|
})
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
@ApiResponse({ status: 403, description: 'Cancelling without `schedules:cancel`' })
|
||||||
|
updateStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateScheduleStatusDto,
|
||||||
|
@Req() req: { user?: unknown },
|
||||||
|
) {
|
||||||
|
// The guard cannot see the body — CANCELLED arrives on the same route as
|
||||||
|
// BOARDING or DELAYED — so the narrower check happens here. `schedules.manage`
|
||||||
|
// is in the list, so a manage holder cancels exactly as they do today; an
|
||||||
|
// `edit`-only holder can retime a trip but not cancel it.
|
||||||
|
if (dto.status === TripStatus.CANCELLED) {
|
||||||
|
assertAnyPassengerPermission(req.user as never, [S.cancel, S.manage, P.admin]);
|
||||||
|
}
|
||||||
return this.service.updateScheduleStatus(id, dto);
|
return this.service.updateScheduleStatus(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(S.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a schedule' })
|
@ApiOperation({ summary: 'Delete a schedule' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@@ -155,7 +187,7 @@ export class SchedulesController {
|
|||||||
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
deleteSchedule(@Param('id') id: string, @Query('cascade') cascade?: string) { return this.service.deleteSchedule(id, cascade === 'true'); }
|
||||||
|
|
||||||
@Post(':id/recalculate-stops')
|
@Post(':id/recalculate-stops')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
|
@ApiOperation({ summary: 'Recompute TripStopTime records from current route travelMinutesToStop values' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
|
recalculateStops(@Param('id') id: string) { return this.service.recalculateStopTimes(id); }
|
||||||
@@ -167,7 +199,7 @@ export class SchedulesController {
|
|||||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||||
|
|
||||||
@Patch(':id/stops/:sequence')
|
@Patch(':id/stops/:sequence')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a stop time' })
|
@ApiOperation({ summary: 'Update a stop time' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||||
@@ -178,7 +210,7 @@ export class SchedulesController {
|
|||||||
) { return this.service.updateStop(id, sequence, dto); }
|
) { return this.service.updateStop(id, sequence, dto); }
|
||||||
|
|
||||||
@Post(':id/delay')
|
@Post(':id/delay')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
||||||
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
||||||
@@ -194,7 +226,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':scheduleId/fares/:seatClassId')
|
@Put(':scheduleId/fares/:seatClassId')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Override fare for a specific seat class on a schedule',
|
summary: 'Override fare for a specific seat class on a schedule',
|
||||||
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
||||||
@@ -242,13 +274,13 @@ records the accumulated delay on the schedule's live status. Does not change sch
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/fares/sync')
|
@Post(':id/fares/sync')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@FareWrite(F.edit) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||||
|
|
||||||
@Post(':id/coaches')
|
@Post(':id/coaches')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(S.edit, S.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
assignCoaches(
|
assignCoaches(
|
||||||
@@ -264,7 +296,7 @@ records the accumulated delay on the schedule's live status. Does not change sch
|
|||||||
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
||||||
|
|
||||||
@Delete(':id/coaches/:coachId')
|
@Delete(':id/coaches/:coachId')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(S.edit, S.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody }
|
|||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SeatClassesService } from './seat-classes.service';
|
import { SeatClassesService } from './seat-classes.service';
|
||||||
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Seat Classes')
|
@ApiTags('Seat Classes')
|
||||||
@@ -26,7 +26,7 @@ export class SeatClassesController {
|
|||||||
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
|
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.tariffRates.create, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create a seat class' })
|
@ApiOperation({ summary: 'Create a seat class' })
|
||||||
@ApiBody({ type: CreateSeatClassDto })
|
@ApiBody({ type: CreateSeatClassDto })
|
||||||
@ApiResponse({ status: 201, description: 'Seat class created' })
|
@ApiResponse({ status: 201, description: 'Seat class created' })
|
||||||
@@ -34,7 +34,7 @@ export class SeatClassesController {
|
|||||||
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
|
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerWrite(PASSENGER_PERMS.tariffRates.edit, PASSENGER_PERMS.tariffRates.manage) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update a seat class' })
|
@ApiOperation({ summary: 'Update a seat class' })
|
||||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||||
@ApiBody({ type: UpdateSeatClassDto })
|
@ApiBody({ type: UpdateSeatClassDto })
|
||||||
@@ -43,7 +43,7 @@ export class SeatClassesController {
|
|||||||
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
|
updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.tariffRates.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete a seat class' })
|
@ApiOperation({ summary: 'Delete a seat class' })
|
||||||
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
@ApiParam({ name: 'id', description: 'Seat class UUID' })
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { AutoAssignHoldDto, BlockSeatDto, HoldSeatsDto, ReleaseHoldDto, SetMaint
|
|||||||
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
|
import { resolveActingUser, RequestWithActingUser } from "../../common/acting-user";
|
||||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||||
import { JwtGuard } from "../../common/jwt.guard";
|
import { JwtGuard } from "../../common/jwt.guard";
|
||||||
import { PassengerStaff } from "../../common/passenger-guards";
|
import { PassengerDelete, PassengerWrite } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
|
|
||||||
@ApiTags("Seats")
|
@ApiTags("Seats")
|
||||||
@@ -35,7 +35,7 @@ export class SeatsController {
|
|||||||
|
|
||||||
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
||||||
@Get('blocks')
|
@Get('blocks')
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
||||||
@ApiResponse({ status: 200, description: 'Blocked seat records' })
|
@ApiResponse({ status: 200, description: 'Blocked seat records' })
|
||||||
@@ -187,7 +187,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("auto-assign-hold")
|
@Post("auto-assign-hold")
|
||||||
@PassengerStaff([PASSENGER_PERMS.bookings.manage])
|
@PassengerWrite(PASSENGER_PERMS.bookings.create, PASSENGER_PERMS.bookings.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
summary: "Auto-assign and hold N seats of a class — staff bulk/group booking only",
|
||||||
@@ -247,7 +247,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
|
|
||||||
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
// ── Seat Block / Unblock ───────────────────────────────────────────────────
|
||||||
@Post(":seatId/block")
|
@Post(":seatId/block")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Block a seat (e.g., maintenance, damage)",
|
summary: "Block a seat (e.g., maintenance, damage)",
|
||||||
@@ -268,7 +268,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":seatId/block")
|
@Delete(":seatId/block")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.block, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({ summary: "Unblock a seat" })
|
@ApiOperation({ summary: "Unblock a seat" })
|
||||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||||
@@ -279,7 +279,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
|
|
||||||
// ── Maintenance ───────────────────────────────────────────────────────────
|
// ── Maintenance ───────────────────────────────────────────────────────────
|
||||||
@Post(":seatId/maintenance")
|
@Post(":seatId/maintenance")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
|
||||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||||
@@ -294,7 +294,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(":seatId/maintenance")
|
@Delete(":seatId/maintenance")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({ summary: "Clear seat maintenance status" })
|
@ApiOperation({ summary: "Clear seat maintenance status" })
|
||||||
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
@ApiParam({ name: "seatId", description: "Seat UUID" })
|
||||||
@@ -305,7 +305,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
|
|
||||||
// ── Remove Seat ────────────────────────────────────────────────────────────
|
// ── Remove Seat ────────────────────────────────────────────────────────────
|
||||||
@Patch(":seatId/remove")
|
@Patch(":seatId/remove")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerDelete(PASSENGER_PERMS.seats.delete)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Remove a seat by marking with negative seatNumber",
|
summary: "Remove a seat by marking with negative seatNumber",
|
||||||
@@ -321,7 +321,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(":seatId/undo-remove")
|
@Patch(":seatId/undo-remove")
|
||||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("IAM-auth")
|
@ApiBearerAuth("IAM-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Undo seat removal by restoring original seatNumber",
|
summary: "Undo seat removal by restoring original seatNumber",
|
||||||
@@ -338,7 +338,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get("export/csv/:scheduleId")
|
@Get("export/csv/:scheduleId")
|
||||||
@UseGuards(JwtGuard)
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("JWT-auth")
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: "Export seats as CSV" })
|
@ApiOperation({ summary: "Export seats as CSV" })
|
||||||
async exportCSV(@Param("scheduleId") scheduleId: string) {
|
async exportCSV(@Param("scheduleId") scheduleId: string) {
|
||||||
@@ -347,7 +347,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("import/preview")
|
@Post("import/preview")
|
||||||
@UseGuards(JwtGuard)
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("JWT-auth")
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: "Preview CSV import" })
|
@ApiOperation({ summary: "Preview CSV import" })
|
||||||
previewCSV(@Body() body: { csv: string }) {
|
previewCSV(@Body() body: { csv: string }) {
|
||||||
@@ -355,7 +355,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("import/commit")
|
@Post("import/commit")
|
||||||
@UseGuards(JwtGuard)
|
@PassengerWrite(PASSENGER_PERMS.seats.create, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("JWT-auth")
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({ summary: "Commit CSV import" })
|
@ApiOperation({ summary: "Commit CSV import" })
|
||||||
importCSV(
|
importCSV(
|
||||||
@@ -367,7 +367,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||||
|
|
||||||
@Get("duplicates")
|
@Get("duplicates")
|
||||||
@UseGuards(JwtGuard)
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("JWT-auth")
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List duplicate seat assignments by schedule date",
|
summary: "List duplicate seat assignments by schedule date",
|
||||||
@@ -418,7 +418,7 @@ Throws 409 with no partial hold created if fewer than the requested seats are av
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post("duplicates/resolve")
|
@Post("duplicates/resolve")
|
||||||
@UseGuards(JwtGuard)
|
@PassengerWrite(PASSENGER_PERMS.seats.edit, PASSENGER_PERMS.seats.manage)
|
||||||
@ApiBearerAuth("JWT-auth")
|
@ApiBearerAuth("JWT-auth")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@ne
|
|||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { StationsService } from './stations.service';
|
import { StationsService } from './stations.service';
|
||||||
import { CreateStationDto } from './stations.dto';
|
import { CreateStationDto } from './stations.dto';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@ApiTags('Stations')
|
@ApiTags('Stations')
|
||||||
@@ -79,7 +79,7 @@ export class StationsController {
|
|||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.stations.create, PASSENGER_PERMS.stations.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Create new station' })
|
@ApiOperation({ summary: 'Create new station' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -105,7 +105,7 @@ export class StationsController {
|
|||||||
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
|
@PassengerWrite(PASSENGER_PERMS.stations.edit, PASSENGER_PERMS.stations.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Update station' })
|
@ApiOperation({ summary: 'Update station' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@@ -134,7 +134,7 @@ export class StationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.stations.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Delete station' })
|
@ApiOperation({ summary: 'Delete station' })
|
||||||
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
@ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query, Req, UseGuards, Delete, Patch, SetMetadata } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger';
|
||||||
import { TicketsService } from './tickets.service';
|
import { TicketsService } from './tickets.service';
|
||||||
import { PassengerStaff, PassengerAdmin } from '../../common/passenger-guards';
|
import { PassengerStaff, PassengerDelete, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
import { resolveActingUser } from '../../common/acting-user';
|
import { resolveActingUser } from '../../common/acting-user';
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('scan-board/:qrCodeOrRef')
|
@Post('scan-board/:qrCodeOrRef')
|
||||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
@PassengerWrite(PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||||
@@ -146,7 +146,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':bookingRef/validate')
|
@Post(':bookingRef/validate')
|
||||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
@PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Validate ticket at gate with audit logging',
|
summary: 'Validate ticket at gate with audit logging',
|
||||||
@@ -194,7 +194,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('validate/offline')
|
@Post('validate/offline')
|
||||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
@PassengerStaff([PASSENGER_PERMS.tickets.board, PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Batch import offline validations',
|
summary: 'Batch import offline validations',
|
||||||
@@ -226,7 +226,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.tickets.delete)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Delete ticket (admin only)',
|
summary: 'Delete ticket (admin only)',
|
||||||
@@ -237,7 +237,7 @@ export class TicketsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/restore')
|
@Patch(':id/restore')
|
||||||
@PassengerStaff(PASSENGER_PERMS.tickets.manage)
|
@PassengerWrite(PASSENGER_PERMS.tickets.edit, PASSENGER_PERMS.tickets.manage)
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
@ApiOperation({ summary: 'Restore a cancelled ticket by resetting its status to ACTIVE' })
|
||||||
restore(@Param('id') id: string) {
|
restore(@Param('id') id: string) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
import { JwtGuard } from '../../common/jwt.guard';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerDelete, PassengerStaff, PassengerWrite } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
import { UpgradeService } from './upgrade.service';
|
import { UpgradeService } from './upgrade.service';
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +18,7 @@ export class UpgradeController {
|
|||||||
constructor(private service: UpgradeService) {}
|
constructor(private service: UpgradeService) {}
|
||||||
|
|
||||||
@Get('upgrade/policies')
|
@Get('upgrade/policies')
|
||||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
@PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
|
@ApiOperation({ summary: 'Every upgrade policy, each with its coach type (fare class)' })
|
||||||
listPolicies() {
|
listPolicies() {
|
||||||
@@ -26,7 +26,7 @@ export class UpgradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('upgrade/policies/available-coach-types')
|
@Get('upgrade/policies/available-coach-types')
|
||||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
@PassengerStaff([PASSENGER_PERMS.upgradePolicies.view, PASSENGER_PERMS.upgradePolicies.manage, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
|
@ApiOperation({ summary: 'Coach types that do not have an upgrade policy yet (add-dialog dropdown)' })
|
||||||
listUnconfiguredCoachTypes() {
|
listUnconfiguredCoachTypes() {
|
||||||
@@ -34,7 +34,7 @@ export class UpgradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('upgrade/policies')
|
@Post('upgrade/policies')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.create, PASSENGER_PERMS.upgradePolicies.manage)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
|
@ApiOperation({ summary: 'Create an upgrade policy for a coach type (admin)' })
|
||||||
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
|
createPolicy(@Req() req: any, @Body() dto: CreateUpgradePolicyDto) {
|
||||||
@@ -42,7 +42,7 @@ export class UpgradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch('upgrade/policies/:coachTypeId')
|
@Patch('upgrade/policies/:coachTypeId')
|
||||||
@PassengerAdmin()
|
@PassengerWrite(PASSENGER_PERMS.upgradePolicies.edit, PASSENGER_PERMS.upgradePolicies.manage)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
|
@ApiOperation({ summary: 'Update the upgrade policy of a coach type (admin)' })
|
||||||
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
|
updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateUpgradePolicyDto) {
|
||||||
@@ -50,7 +50,7 @@ export class UpgradeController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('upgrade/policies/:coachTypeId')
|
@Delete('upgrade/policies/:coachTypeId')
|
||||||
@PassengerAdmin()
|
@PassengerDelete(PASSENGER_PERMS.upgradePolicies.delete)
|
||||||
@ApiBearerAuth('JWT-auth')
|
@ApiBearerAuth('JWT-auth')
|
||||||
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
|
@ApiOperation({ summary: 'Delete an upgrade policy — the class can then be neither left nor entered (admin)' })
|
||||||
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
|
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
|||||||
perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'),
|
perm('2ce6d0e3-3426-4424-a239-3ddf95b001c4', 'edr_passenger_app:schedules:cancel', 'Cancel schedules'),
|
||||||
perm('045d3f55-d5fc-4ef4-8d4f-949496b77c25', 'edr_passenger_app:seats:block', 'Block and unblock seats'),
|
perm('045d3f55-d5fc-4ef4-8d4f-949496b77c25', 'edr_passenger_app:seats:block', 'Block and unblock seats'),
|
||||||
perm('8ac8f5de-26b0-469b-993e-2abfa005d223', 'edr_passenger_app:packages:publish', 'Activate and deactivate packages'),
|
perm('8ac8f5de-26b0-469b-993e-2abfa005d223', 'edr_passenger_app:packages:publish', 'Activate and deactivate packages'),
|
||||||
|
perm('4c263527-9868-4ec6-8861-039cd5e37948', 'edr_passenger_app:tickets:board', 'Board passengers'),
|
||||||
|
perm('635a92ec-def7-4373-a076-5437ccac09b8', 'edr_passenger_app:payments:supplementary', 'Send supplementary payment'),
|
||||||
|
perm('7f6deaf2-37c9-4420-ab29-9278266e3a5d', 'edr_passenger_app:excess_baggage:charge', 'Send excess luggage payment'),
|
||||||
|
|
||||||
// ── Schedule fares — split out of `schedules:manage` ────────────────────────────
|
// ── Schedule fares — split out of `schedules:manage` ────────────────────────────
|
||||||
perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'),
|
perm('7e7406b3-77dc-422a-b5de-a8286f973b38', 'edr_passenger_app:schedule_fares:view', 'View schedule fares'),
|
||||||
@@ -149,6 +152,19 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
|
|||||||
perm('2a8fb77e-4bf6-42e0-80ca-53ca142b1b23', 'edr_passenger_app:reports_payments:view', 'View payments report'),
|
perm('2a8fb77e-4bf6-42e0-80ca-53ca142b1b23', 'edr_passenger_app:reports_payments:view', 'View payments report'),
|
||||||
perm('3476bbe6-47f5-4040-8b3c-88b417b1c0c2', 'edr_passenger_app:reports_catalog:view', 'View generated report catalog'),
|
perm('3476bbe6-47f5-4040-8b3c-88b417b1c0c2', 'edr_passenger_app:reports_catalog:view', 'View generated report catalog'),
|
||||||
|
|
||||||
|
|
||||||
|
// ── Reschedule & upgrade policies ────────────────────────────────────────────
|
||||||
|
perm('9d27002e-7ee1-445a-b732-347f3b18d89a', 'edr_passenger_app:reschedule_policies:view', 'View reschedule policies'),
|
||||||
|
perm('19b50492-8915-4a05-8c15-61b39fe244d1', 'edr_passenger_app:reschedule_policies:manage', 'Manage reschedule policies'),
|
||||||
|
perm('9955afb1-61ee-4dd2-a863-b85631de730b', 'edr_passenger_app:reschedule_policies:create', 'Create reschedule policies'),
|
||||||
|
perm('c7d0e2e2-7894-40ad-95af-a91740658ae4', 'edr_passenger_app:reschedule_policies:edit', 'Edit reschedule policies'),
|
||||||
|
perm('a45a796e-4fd2-46b0-8d0a-1f2aa227763a', 'edr_passenger_app:reschedule_policies:delete', 'Delete reschedule policies'),
|
||||||
|
perm('3f088266-af6a-49b8-bfc6-287e8b459022', 'edr_passenger_app:upgrade_policies:view', 'View upgrade policies'),
|
||||||
|
perm('678363f4-cf2e-4488-80ad-9ed8b7760d55', 'edr_passenger_app:upgrade_policies:manage', 'Manage upgrade policies'),
|
||||||
|
perm('1383de71-b308-4fc4-9900-6f045d8a74d0', 'edr_passenger_app:upgrade_policies:create', 'Create upgrade policies'),
|
||||||
|
perm('c2842698-0998-4675-ba28-8cbc4e9ac1a8', 'edr_passenger_app:upgrade_policies:edit', 'Edit upgrade policies'),
|
||||||
|
perm('f43ef123-65bd-49c3-b5c2-c943f5f4aaf2', 'edr_passenger_app:upgrade_policies:delete', 'Delete upgrade policies'),
|
||||||
|
|
||||||
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
|
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -164,6 +180,25 @@ export const PASSENGER_PERMS = {
|
|||||||
edit: 'edr_passenger_app:bookings:edit',
|
edit: 'edr_passenger_app:bookings:edit',
|
||||||
delete: 'edr_passenger_app:bookings:delete',
|
delete: 'edr_passenger_app:bookings:delete',
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Fee/window policy for changing a confirmed booking. Its own resource rather than a
|
||||||
|
* booking action: an agent who can manage bookings must not be able to rewrite the fee
|
||||||
|
* schedule those bookings are priced against.
|
||||||
|
*/
|
||||||
|
reschedulePolicies: {
|
||||||
|
view: 'edr_passenger_app:reschedule_policies:view',
|
||||||
|
manage: 'edr_passenger_app:reschedule_policies:manage',
|
||||||
|
create: 'edr_passenger_app:reschedule_policies:create',
|
||||||
|
edit: 'edr_passenger_app:reschedule_policies:edit',
|
||||||
|
delete: 'edr_passenger_app:reschedule_policies:delete',
|
||||||
|
},
|
||||||
|
upgradePolicies: {
|
||||||
|
view: 'edr_passenger_app:upgrade_policies:view',
|
||||||
|
manage: 'edr_passenger_app:upgrade_policies:manage',
|
||||||
|
create: 'edr_passenger_app:upgrade_policies:create',
|
||||||
|
edit: 'edr_passenger_app:upgrade_policies:edit',
|
||||||
|
delete: 'edr_passenger_app:upgrade_policies:delete',
|
||||||
|
},
|
||||||
passengers: {
|
passengers: {
|
||||||
view: 'edr_passenger_app:passengers:view',
|
view: 'edr_passenger_app:passengers:view',
|
||||||
manage: 'edr_passenger_app:passengers:manage',
|
manage: 'edr_passenger_app:passengers:manage',
|
||||||
@@ -175,6 +210,8 @@ export const PASSENGER_PERMS = {
|
|||||||
view: 'edr_passenger_app:tickets:view',
|
view: 'edr_passenger_app:tickets:view',
|
||||||
manage: 'edr_passenger_app:tickets:manage',
|
manage: 'edr_passenger_app:tickets:manage',
|
||||||
generate: 'edr_passenger_app:tickets:generate',
|
generate: 'edr_passenger_app:tickets:generate',
|
||||||
|
/** Marking a passenger boarded — gate scanning separately from editing a ticket. */
|
||||||
|
board: 'edr_passenger_app:tickets:board',
|
||||||
create: 'edr_passenger_app:tickets:create',
|
create: 'edr_passenger_app:tickets:create',
|
||||||
edit: 'edr_passenger_app:tickets:edit',
|
edit: 'edr_passenger_app:tickets:edit',
|
||||||
delete: 'edr_passenger_app:tickets:delete',
|
delete: 'edr_passenger_app:tickets:delete',
|
||||||
@@ -183,6 +220,8 @@ export const PASSENGER_PERMS = {
|
|||||||
view: 'edr_passenger_app:payments:view',
|
view: 'edr_passenger_app:payments:view',
|
||||||
manage: 'edr_passenger_app:payments:manage',
|
manage: 'edr_passenger_app:payments:manage',
|
||||||
create: 'edr_passenger_app:payments:create',
|
create: 'edr_passenger_app:payments:create',
|
||||||
|
/** Raising and re-sending a supplementary charge — bills a passenger and sends a pay link. */
|
||||||
|
supplementary: 'edr_passenger_app:payments:supplementary',
|
||||||
edit: 'edr_passenger_app:payments:edit',
|
edit: 'edr_passenger_app:payments:edit',
|
||||||
delete: 'edr_passenger_app:payments:delete',
|
delete: 'edr_passenger_app:payments:delete',
|
||||||
// legacy keys — retained as aliases for backward compatibility
|
// legacy keys — retained as aliases for backward compatibility
|
||||||
@@ -190,6 +229,13 @@ export const PASSENGER_PERMS = {
|
|||||||
refund: 'edr_passenger_app:payments:refund',
|
refund: 'edr_passenger_app:payments:refund',
|
||||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Excess luggage. Only the charge action is modelled: logging one bills the passenger
|
||||||
|
* and sends them a payment link, which is the part worth granting separately.
|
||||||
|
*/
|
||||||
|
excessBaggage: {
|
||||||
|
charge: 'edr_passenger_app:excess_baggage:charge',
|
||||||
|
},
|
||||||
paymentMethods: {
|
paymentMethods: {
|
||||||
view: 'edr_passenger_app:payment_methods:view',
|
view: 'edr_passenger_app:payment_methods:view',
|
||||||
manage: 'edr_passenger_app:payment_methods:manage',
|
manage: 'edr_passenger_app:payment_methods:manage',
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
import { formatCurrency, formatDateTime } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
<div className="bg-muted/40 rounded-lg p-3">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
@@ -27,9 +30,13 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function AgentsPage() {
|
function AgentsPageContent() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.agents.create, PERMS.agents.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.agents.edit, PERMS.agents.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.agents.delete);
|
||||||
const [filters, setFilters] = useState({ search: '', active: '' });
|
const [filters, setFilters] = useState({ search: '', active: '' });
|
||||||
const [selected, setSelected] = useState<any>(null);
|
const [selected, setSelected] = useState<any>(null);
|
||||||
const [createModal, setCreateModal] = useState(false);
|
const [createModal, setCreateModal] = useState(false);
|
||||||
@@ -131,6 +138,7 @@ export default function AgentsPage() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (agent: any) => openEditModal(agent),
|
onClick: (agent: any) => openEditModal(agent),
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
@@ -143,6 +151,7 @@ export default function AgentsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); },
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -156,7 +165,7 @@ export default function AgentsPage() {
|
|||||||
<h1 className="text-2xl font-bold">Agents</h1>
|
<h1 className="text-2xl font-bold">Agents</h1>
|
||||||
<p className="text-muted-foreground">Manage agents and their operations</p>
|
<p className="text-muted-foreground">Manage agents and their operations</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Plus} onClick={openCreateModal}>Add Agent</ActionButton>
|
<ActionButton icon={Plus} onClick={openCreateModal} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create agents'}>Add Agent</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -394,3 +403,11 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.agents.view}>
|
||||||
|
<AgentsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { appReleasesApi } from '@/lib/api';
|
import { appReleasesApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
|
const EMPTY_FORM = { os: 'android', version: '', forceUpdate: false, storeLink: '', notes: '' };
|
||||||
|
|
||||||
export default function AppReleasesPage() {
|
function AppReleasesPageContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
@@ -184,3 +186,11 @@ export default function AppReleasesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AppReleasesPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.admin}>
|
||||||
|
<AppReleasesPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { useAuthStore } from '@/lib/auth-store';
|
|||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Header from '@/components/layout/Header';
|
import Header from '@/components/layout/Header';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { useWritePermission } from '@/lib/use-permission';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
// Add QR Scanner component
|
// Add QR Scanner component
|
||||||
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
|
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
|
||||||
@@ -315,7 +318,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BoardingPage() {
|
function BoardingPageContent() {
|
||||||
const [qrInput, setQrInput] = useState('');
|
const [qrInput, setQrInput] = useState('');
|
||||||
const [lastScanned, setLastScanned] = useState<any>(null);
|
const [lastScanned, setLastScanned] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -341,6 +344,8 @@ export default function BoardingPage() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
|
||||||
|
|
||||||
const boardingMutation = useMutation({
|
const boardingMutation = useMutation({
|
||||||
mutationFn: (qrCodeOrRef: string) =>
|
mutationFn: (qrCodeOrRef: string) =>
|
||||||
ticketsApi.scanAndBoard(qrCodeOrRef, {
|
ticketsApi.scanAndBoard(qrCodeOrRef, {
|
||||||
@@ -482,7 +487,8 @@ export default function BoardingPage() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleButtonClick}
|
onClick={handleButtonClick}
|
||||||
disabled={boardingMutation.isPending || !qrInput.trim()}
|
disabled={boardingMutation.isPending || !qrInput.trim() || !canBoard}
|
||||||
|
title={canBoard ? undefined : 'You do not have permission to board passengers'}
|
||||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
|
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
|
||||||
text-white font-semibold py-4 px-6 rounded-xl transition-colors
|
text-white font-semibold py-4 px-6 rounded-xl transition-colors
|
||||||
disabled:cursor-not-allowed text-lg"
|
disabled:cursor-not-allowed text-lg"
|
||||||
@@ -606,3 +612,11 @@ export default function BoardingPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function BoardingPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.tickets.board}>
|
||||||
|
<BoardingPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { usePermission } from '@/lib/use-permission';
|
import { useDeletePermission, usePermission } from '@/lib/use-permission';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { bookingsApi, apiClient } from '@/lib/api';
|
import { bookingsApi, apiClient } from '@/lib/api';
|
||||||
@@ -30,7 +30,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
function BookingsPageContent() {
|
function BookingsPageContent() {
|
||||||
const canManage = usePermission(PERMS.bookings.manage);
|
const canDeleteBooking = useDeletePermission(PERMS.bookings.delete);
|
||||||
// Mirrors the API guard on POST /payments/:bookingId/force-confirm —
|
// Mirrors the API guard on POST /payments/:bookingId/force-confirm —
|
||||||
// tickets:generate, with the usual super-admin / org-admin bypass.
|
// tickets:generate, with the usual super-admin / org-admin bypass.
|
||||||
const canGenerateTicket = usePermission(PERMS.tickets.generate);
|
const canGenerateTicket = usePermission(PERMS.tickets.generate);
|
||||||
@@ -302,7 +302,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: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') },
|
{ label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(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, show: () => canDeleteBooking },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { seatClassesApi, apiClient } from '@/lib/api';
|
|||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
function ClassesPageContent() {
|
function ClassesPageContent() {
|
||||||
const [filters, setFilters] = useState({ search: '' });
|
const [filters, setFilters] = useState({ search: '' });
|
||||||
@@ -21,6 +22,10 @@ function ClassesPageContent() {
|
|||||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.classes.create, PERMS.classes.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.classes.edit, PERMS.classes.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.classes.delete);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['classes', filters],
|
queryKey: ['classes', filters],
|
||||||
queryFn: () => seatClassesApi.getAll(),
|
queryFn: () => seatClassesApi.getAll(),
|
||||||
@@ -180,12 +185,14 @@ function ClassesPageContent() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (cls: any) => handleOpenModal(cls),
|
onClick: (cls: any) => handleOpenModal(cls),
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
type Tab = 'types' | 'coaches';
|
type Tab = 'types' | 'coaches';
|
||||||
|
|
||||||
@@ -155,6 +156,10 @@ function CoachesPageContent() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.coaches.create, PERMS.coaches.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.coaches.edit, PERMS.coaches.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.coaches.delete);
|
||||||
|
|
||||||
// Coach Types Queries
|
// Coach Types Queries
|
||||||
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
const { data: coachTypesData, isLoading: typesLoading } = useQuery({
|
||||||
queryKey: ['coach-types'],
|
queryKey: ['coach-types'],
|
||||||
@@ -438,6 +443,7 @@ function CoachesPageContent() {
|
|||||||
const coachTypeActions = [
|
const coachTypeActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (item: any) => {
|
onClick: (item: any) => {
|
||||||
setEditingItem({ ...item, isCoachType: true });
|
setEditingItem({ ...item, isCoachType: true });
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -447,6 +453,7 @@ function CoachesPageContent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (item: any) => handleDelete(item, true),
|
onClick: (item: any) => handleDelete(item, true),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -456,6 +463,7 @@ function CoachesPageContent() {
|
|||||||
const coachActions = [
|
const coachActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (item: any) => {
|
onClick: (item: any) => {
|
||||||
setEditingItem({ ...item, isCoach: true });
|
setEditingItem({ ...item, isCoach: true });
|
||||||
setSelectedCoachTypeId(item.coachTypeId || '');
|
setSelectedCoachTypeId(item.coachTypeId || '');
|
||||||
@@ -467,6 +475,7 @@ function CoachesPageContent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (item: any) => handleDelete(item, false),
|
onClick: (item: any) => handleDelete(item, false),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -489,6 +498,9 @@ function CoachesPageContent() {
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create coaches'}
|
||||||
>
|
>
|
||||||
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
{activeTab === 'types' ? 'Add Coach Type' : 'Add Coach'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
interface ExchangeRate {
|
interface ExchangeRate {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,6 +37,10 @@ export default function CurrenciesPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.currencies.create, PERMS.currencies.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.currencies.edit, PERMS.currencies.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.currencies.delete);
|
||||||
|
|
||||||
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
|
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
|
||||||
queryKey: ['currencies'],
|
queryKey: ['currencies'],
|
||||||
queryFn: () => apiClient.get('/currencies'),
|
queryFn: () => apiClient.get('/currencies'),
|
||||||
@@ -124,12 +130,14 @@ export default function CurrenciesPage() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
|
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
|
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -143,7 +151,7 @@ export default function CurrenciesPage() {
|
|||||||
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
||||||
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
|
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>
|
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create currency rates'}>
|
||||||
Add Rate
|
Add Rate
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
||||||
import { seatsApi } from '@/lib/api';
|
import { seatsApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import { useWritePermission } from '@/lib/use-permission';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -300,9 +303,10 @@ interface CoachCardProps {
|
|||||||
coach: CoachReport;
|
coach: CoachReport;
|
||||||
schedule: ScheduleReport;
|
schedule: ScheduleReport;
|
||||||
onResolve: () => void;
|
onResolve: () => void;
|
||||||
|
canResolve?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
function CoachCard({ coach, schedule, onResolve, canResolve = false }: CoachCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const hasDuplicates = coach.duplicates.length > 0;
|
const hasDuplicates = coach.duplicates.length > 0;
|
||||||
|
|
||||||
@@ -330,7 +334,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
|||||||
{hasDuplicates && (
|
{hasDuplicates && (
|
||||||
<button
|
<button
|
||||||
onClick={onResolve}
|
onClick={onResolve}
|
||||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
disabled={!canResolve}
|
||||||
|
title={canResolve ? undefined : 'You do not have permission to resolve duplicate seats'}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Resolve
|
Resolve
|
||||||
</button>
|
</button>
|
||||||
@@ -379,7 +385,9 @@ function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
|||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DiscrepancyPage() {
|
function DiscrepancyPageContent() {
|
||||||
|
// POST /seats/duplicates/resolve is guarded by seats:edit (it deletes seat rows).
|
||||||
|
const canResolveDuplicates = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
|
||||||
const [date, setDate] = useState(today());
|
const [date, setDate] = useState(today());
|
||||||
const [searchDate, setSearchDate] = useState('');
|
const [searchDate, setSearchDate] = useState('');
|
||||||
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
||||||
@@ -488,6 +496,7 @@ export default function DiscrepancyPage() {
|
|||||||
coach={coach}
|
coach={coach}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||||
|
canResolve={canResolveDuplicates}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -517,3 +526,11 @@ export default function DiscrepancyPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DiscrepancyPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.seats.view}>
|
||||||
|
<DiscrepancyPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
|
import { excessBaggageApi, apiClient, bookingsApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { useWritePermission } from '@/lib/use-permission';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const STATUS_VARIANT: Record<string, any> = {
|
const STATUS_VARIANT: Record<string, any> = {
|
||||||
PENDING: 'PENDING',
|
PENDING: 'PENDING',
|
||||||
@@ -19,8 +22,11 @@ const STATUS_VARIANT: Record<string, any> = {
|
|||||||
WAIVED: 'CANCELLED',
|
WAIVED: 'CANCELLED',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExcessBaggagePage() {
|
function ExcessBaggagePageContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Logging or resending a luggage charge bills the passenger and sends a pay link.
|
||||||
|
const canCharge = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
|
||||||
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' });
|
||||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -173,7 +179,7 @@ export default function ExcessBaggagePage() {
|
|||||||
icon: Send,
|
icon: Send,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
|
onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); },
|
||||||
show: (c: any) => c.status === 'PENDING',
|
show: (c: any) => canCharge && c.status === 'PENDING',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Waive',
|
label: 'Waive',
|
||||||
@@ -202,7 +208,12 @@ export default function ExcessBaggagePage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
<h1 className="text-2xl font-bold text-foreground">Excess Lugagge</h1>
|
||||||
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
<p className="text-muted-foreground">Track and manage excess luggage charges at boarding</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Plus} onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}>
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
disabled={!canCharge}
|
||||||
|
title={canCharge ? undefined : 'You do not have permission to log a luggage charge'}
|
||||||
|
onClick={() => { setLogModal(true); setLogError(null); setLogForm({ bookingReference: '', excessWeightKg: '', collectCash: false, paymentPhone: '' }); }}
|
||||||
|
>
|
||||||
Log Excess Luggage
|
Log Excess Luggage
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -421,3 +432,11 @@ export default function ExcessBaggagePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ExcessBaggagePage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.bookings.view}>
|
||||||
|
<ExcessBaggagePageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
|
|||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { fraudApi } from '@/lib/api';
|
import { fraudApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
<div className="bg-muted/40 rounded-lg p-3">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
@@ -30,11 +33,13 @@ const SEVERITY_GRAD: Record<string, string> = {
|
|||||||
LOW: 'from-blue-500 to-blue-600',
|
LOW: 'from-blue-500 to-blue-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function FraudDetectionPage() {
|
function FraudDetectionPageContent() {
|
||||||
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
const [filters, setFilters] = useState({ search: '', severity: '', status: '' });
|
||||||
const [selected, setSelected] = useState<any>(null);
|
const [selected, setSelected] = useState<any>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canManageFraud = useWritePermission(PERMS.fraud.edit, PERMS.fraud.manage);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['fraud-alerts', filters],
|
queryKey: ['fraud-alerts', filters],
|
||||||
queryFn: () => fraudApi.getAlerts(filters),
|
queryFn: () => fraudApi.getAlerts(filters),
|
||||||
@@ -130,10 +135,11 @@ export default function FraudDetectionPage() {
|
|||||||
onClick: handleAcknowledge,
|
onClick: handleAcknowledge,
|
||||||
variant: 'primary' as const,
|
variant: 'primary' as const,
|
||||||
icon: CheckCircle,
|
icon: CheckCircle,
|
||||||
show: (alert: any) => !alert.acknowledged,
|
show: (alert: any) => canManageFraud && !alert.acknowledged,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Block User',
|
label: 'Block User',
|
||||||
|
show: () => canManageFraud,
|
||||||
onClick: handleBlockUser,
|
onClick: handleBlockUser,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Ban,
|
icon: Ban,
|
||||||
@@ -301,3 +307,11 @@ export default function FraudDetectionPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function FraudDetectionPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.fraud.view}>
|
||||||
|
<FraudDetectionPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import { notificationsApi } from '@/lib/api';
|
import { notificationsApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
const CHANNEL_OPTIONS = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
function NotificationsPageContent() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [editing, setEditing] = useState<any | null>(null);
|
const [editing, setEditing] = useState<any | null>(null);
|
||||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||||
@@ -313,3 +315,11 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.notifications.send}>
|
||||||
|
<NotificationsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import ActionButton from '@/components/ui/ActionButton';
|
|||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { reportsApi } from '@/lib/api';
|
import { reportsApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
export default function OperationalReportsPage() {
|
function OperationalReportsPageContent() {
|
||||||
const [filters, setFilters] = useState({ search: '', reportType: '' });
|
const [filters, setFilters] = useState({ search: '', reportType: '' });
|
||||||
const [selectedReport, setSelectedReport] = useState<any>(null);
|
const [selectedReport, setSelectedReport] = useState<any>(null);
|
||||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
@@ -465,3 +467,11 @@ export default function OperationalReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function OperationalReportsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.catalog.view, PERMS.reports.view]}>
|
||||||
|
<OperationalReportsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import { packagesApi } from '@/lib/api';
|
import { packagesApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
import { getErrorMessage } from '@/lib/api-client';
|
import { getErrorMessage } from '@/lib/api-client';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
<div className="bg-muted/40 rounded-lg p-3">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
@@ -25,7 +27,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
|||||||
</h3>
|
</h3>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function PackageBookingsPage() {
|
function PackageBookingsPageContent() {
|
||||||
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
|
const [filters, setFilters] = useState({ packageId: '', status: '', page: 1, pageSize: 20 });
|
||||||
const [selected, setSelected] = useState<any>(null);
|
const [selected, setSelected] = useState<any>(null);
|
||||||
|
|
||||||
@@ -264,3 +266,11 @@ export default function PackageBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PackageBookingsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.packages.view}>
|
||||||
|
<PackageBookingsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import ActionButton from '@/components/ui/ActionButton';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { packageInquiriesApi, packagesApi } from '@/lib/api';
|
import { packageInquiriesApi, packagesApi } from '@/lib/api';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
|
const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED'];
|
||||||
|
|
||||||
@@ -19,12 +22,15 @@ const statusVariant: Record<string, string> = {
|
|||||||
CLOSED: 'default',
|
CLOSED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PackageInquiriesPage() {
|
function PackageInquiriesPageContent() {
|
||||||
const [filters, setFilters] = useState({ packageId: '', status: '' });
|
const [filters, setFilters] = useState({ packageId: '', status: '' });
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<any>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canEdit = useWritePermission(PERMS.inquiries.edit, PERMS.inquiries.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.inquiries.delete);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['package-inquiries', filters],
|
queryKey: ['package-inquiries', filters],
|
||||||
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
|
queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }),
|
||||||
@@ -112,6 +118,8 @@ export default function PackageInquiriesPage() {
|
|||||||
<select
|
<select
|
||||||
className="input py-1 text-xs"
|
className="input py-1 text-xs"
|
||||||
value={row.status}
|
value={row.status}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title={canEdit ? undefined : 'You do not have permission to change an inquiry status'}
|
||||||
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
|
onChange={(e) => statusMutation.mutate({ id: row.id, status: e.target.value })}
|
||||||
>
|
>
|
||||||
{STATUSES.map((s) => (
|
{STATUSES.map((s) => (
|
||||||
@@ -125,6 +133,7 @@ export default function PackageInquiriesPage() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
|
onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); },
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -191,3 +200,11 @@ export default function PackageInquiriesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PackageInquiriesPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.inquiries.view}>
|
||||||
|
<PackageInquiriesPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api';
|
||||||
import { getErrorMessage } from '@/lib/api-client';
|
import { getErrorMessage } from '@/lib/api-client';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
// Mirrors the backend's own limits (packages/package-image-upload.options.ts) so a bad file is
|
||||||
// rejected instantly client-side instead of round-tripping to the server first.
|
// rejected instantly client-side instead of round-tripping to the server first.
|
||||||
@@ -33,7 +36,7 @@ const emptyForm = {
|
|||||||
validFrom: '', validUntil: '',
|
validFrom: '', validUntil: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PackagesPage() {
|
function PackagesPageContent() {
|
||||||
const [page] = useState(1);
|
const [page] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
@@ -65,6 +68,11 @@ export default function PackagesPage() {
|
|||||||
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
const [removeImageConfirm, setRemoveImageConfirm] = useState<any>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.packages.create, PERMS.packages.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.packages.edit, PERMS.packages.manage);
|
||||||
|
const canPublish = useWritePermission(PERMS.packages.publish, PERMS.packages.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.packages.delete);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['packages', page],
|
queryKey: ['packages', page],
|
||||||
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
|
queryFn: () => packagesApi.getAll({ page, pageSize: 20 }),
|
||||||
@@ -368,23 +376,25 @@ export default function PackagesPage() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
|
{ label: 'View', onClick: (p: any) => setViewPackage(p), variant: 'secondary' as const, icon: Eye },
|
||||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||||
{
|
{
|
||||||
label: 'Tiers', icon: Layers, variant: 'secondary' as const,
|
label: 'Tiers', icon: Layers, variant: 'secondary' as const,
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
|
onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
|
label: 'Activate', icon: CheckCircle, variant: 'primary' as const,
|
||||||
onClick: (p: any) => setActivateConfirm(p),
|
onClick: (p: any) => setActivateConfirm(p),
|
||||||
show: (p: any) => p.status !== 'ACTIVE',
|
show: (p: any) => canPublish && p.status !== 'ACTIVE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
|
label: 'Deactivate', icon: CheckCircle, variant: 'secondary' as const,
|
||||||
onClick: (p: any) => setDeactivateConfirm(p),
|
onClick: (p: any) => setDeactivateConfirm(p),
|
||||||
show: (p: any) => p.status === 'ACTIVE',
|
show: (p: any) => canPublish && p.status === 'ACTIVE',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
label: 'Delete', icon: Trash2, variant: 'danger' as const,
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
|
onClick: (p: any) => { setDeletePackageError(null); setDeletePackageCascade(false); setDeletePackageConfirm(p); },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -407,7 +417,7 @@ export default function PackagesPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">Packages</h1>
|
<h1 className="text-2xl font-bold text-foreground">Packages</h1>
|
||||||
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
|
<p className="text-muted-foreground">Manage travel packages and pilgrimages</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Plus} onClick={openCreate}>New Package</ActionButton>
|
<ActionButton icon={Plus} onClick={openCreate} disabled={!canCreate} title={canCreate ? undefined : 'You do not have permission to create packages'}>New Package</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* The package itself may already be saved and this modal closed by the time an image
|
{/* The package itself may already be saved and this modal closed by the time an image
|
||||||
@@ -827,3 +837,11 @@ export default function PackagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PackagesPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.packages.view}>
|
||||||
|
<PackagesPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|||||||
import { passengersApi, apiClient } from '@/lib/api';
|
import { passengersApi, apiClient } from '@/lib/api';
|
||||||
import { formatDate, formatDateTime } from '@/lib/utils';
|
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||||
import { PassengerFilters } from '@/types';
|
import { PassengerFilters } from '@/types';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
|
||||||
<div className="bg-muted/40 rounded-lg p-3">
|
<div className="bg-muted/40 rounded-lg p-3">
|
||||||
@@ -33,7 +36,7 @@ const TIER_COLORS: Record<string, string> = {
|
|||||||
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
PLATINUM: 'bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-400 border-indigo-200 dark:border-indigo-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PassengersPage() {
|
function PassengersPageContent() {
|
||||||
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
|
const [filters, setFilters] = useState<PassengerFilters>({ page: 1, pageSize: 20, search: '', role: 'PASSENGER' });
|
||||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||||
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
|
const [extraFilters, setExtraFilters] = useState({ gender: '', nationality: '', dateFrom: '', dateTo: '' });
|
||||||
@@ -51,6 +54,8 @@ export default function PassengersPage() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canDelete = useDeletePermission(PERMS.passengers.delete);
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
|
mutationFn: ({ id, cascade }: { id: string; cascade: boolean }) => passengersApi.delete(id, cascade),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -156,7 +161,7 @@ export default function PassengersPage() {
|
|||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
|
{ label: 'View Details', onClick: (p: any) => setSelectedPassenger(p), variant: 'secondary' as const, icon: Eye },
|
||||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2 },
|
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteConfirm({ isOpen: true, passenger: p }); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -448,3 +453,11 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PassengersPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.passengers.view}>
|
||||||
|
<PassengersPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient, paymentsApi } from '@/lib/api';
|
import { apiClient, paymentsApi } from '@/lib/api';
|
||||||
import { usePermission } from '@/lib/use-permission';
|
import { usePermission } from '@/lib/use-permission';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
export default function PaymentMethodsPage() {
|
function PaymentMethodsPageContent() {
|
||||||
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
|
const canManageMethods = usePermission(PERMS.paymentMethods.manage);
|
||||||
const canManageAdmin = usePermission(PERMS.admin);
|
const canManageAdmin = usePermission(PERMS.admin);
|
||||||
const canManage = canManageMethods || canManageAdmin;
|
const canManage = canManageMethods || canManageAdmin;
|
||||||
@@ -418,3 +419,11 @@ export default function PaymentMethodsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PaymentMethodsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.paymentMethods.view}>
|
||||||
|
<PaymentMethodsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
import SupplementaryChargesModal from './SupplementaryChargesModal';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
import {
|
import {
|
||||||
useSupplementaryCharges,
|
useSupplementaryCharges,
|
||||||
useMarkSupplementaryPaid,
|
useMarkSupplementaryPaid,
|
||||||
@@ -120,6 +123,10 @@ function PaymentsPageContent() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Raising a supplementary charge bills a passenger and sends a pay link — its own grant.
|
||||||
|
const canRaiseCharge = useWritePermission(PERMS.payments.supplementary, PERMS.payments.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.payments.delete);
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/payments/${id}`),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -209,7 +216,7 @@ function PaymentsPageContent() {
|
|||||||
|
|
||||||
const paymentActions = [
|
const paymentActions = [
|
||||||
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
|
{ label: 'View Details', onClick: (p: any) => setSelectedPayment(p), variant: 'secondary' as const, icon: Eye },
|
||||||
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
{ label: 'Delete', onClick: (p: any) => { setDeleteError(null); setPaymentToDelete(p); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2, show: () => canDelete },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -221,7 +228,7 @@ function PaymentsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{pageTab === 'supplementary' && (
|
{pageTab === 'supplementary' && (
|
||||||
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Raise Charge</ActionButton>
|
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)} disabled={!canRaiseCharge} title={canRaiseCharge ? undefined : 'You do not have permission to raise a supplementary charge'}>Raise Charge</ActionButton>
|
||||||
)}
|
)}
|
||||||
{pageTab === 'payments' && (
|
{pageTab === 'payments' && (
|
||||||
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
|
||||||
@@ -531,8 +538,10 @@ function PaymentsPageContent() {
|
|||||||
|
|
||||||
export default function PaymentsPage() {
|
export default function PaymentsPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<PermissionGuard permission={PERMS.payments.view}>
|
||||||
<PaymentsPageContent />
|
<Suspense fallback={null}>
|
||||||
</Suspense>
|
<PaymentsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
</PermissionGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ import { usePagination } from "@/lib/use-pagination";
|
|||||||
import { formatCurrency, formatDateTime } from "@/lib/utils";
|
import { formatCurrency, formatDateTime } from "@/lib/utils";
|
||||||
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
import { categoricalColor, getChartPalette } from "@/lib/chart-palette";
|
||||||
import { useTheme } from "@/lib/theme-store";
|
import { useTheme } from "@/lib/theme-store";
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface RouteOption {
|
interface RouteOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,7 +73,7 @@ function reasonLabel(category: string | null): string {
|
|||||||
|
|
||||||
const TABLE_PAGE_SIZE = 25;
|
const TABLE_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function BlockedSeatRevenueLossPage() {
|
function BlockedSeatRevenueLossPageContent() {
|
||||||
const isDark = useTheme((s) => s.isDark);
|
const isDark = useTheme((s) => s.isDark);
|
||||||
const palette = getChartPalette(isDark);
|
const palette = getChartPalette(isDark);
|
||||||
|
|
||||||
@@ -917,3 +919,11 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function BlockedSeatRevenueLossPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.blockedSeats.view, PERMS.reports.view]}>
|
||||||
|
<BlockedSeatRevenueLossPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import ActionButton from "@/components/ui/ActionButton";
|
|||||||
import { formatDateTime } from "@/lib/utils";
|
import { formatDateTime } from "@/lib/utils";
|
||||||
import Pagination from "@/components/ui/Pagination";
|
import Pagination from "@/components/ui/Pagination";
|
||||||
import { usePagination } from "@/lib/use-pagination";
|
import { usePagination } from "@/lib/use-pagination";
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface ScheduleOption {
|
interface ScheduleOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,7 +52,7 @@ interface BoardingReport {
|
|||||||
|
|
||||||
type Tab = "summary" | "details";
|
type Tab = "summary" | "details";
|
||||||
|
|
||||||
export default function BoardingReportPage() {
|
function BoardingReportPageContent() {
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState("");
|
||||||
const [tab, setTab] = useState<Tab>("summary");
|
const [tab, setTab] = useState<Tab>("summary");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
@@ -380,3 +382,11 @@ export default function BoardingReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function BoardingReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.boarding.view, PERMS.reports.view]}>
|
||||||
|
<BoardingReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { apiClient } from "@/lib/api-client";
|
|||||||
import ActionButton from "@/components/ui/ActionButton";
|
import ActionButton from "@/components/ui/ActionButton";
|
||||||
import Pagination from "@/components/ui/Pagination";
|
import Pagination from "@/components/ui/Pagination";
|
||||||
import { usePagination } from "@/lib/use-pagination";
|
import { usePagination } from "@/lib/use-pagination";
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface ScheduleOption {
|
interface ScheduleOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,7 +30,7 @@ interface CoachUtilizationRow {
|
|||||||
totalBookings: number;
|
totalBookings: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CoachUtilizationReportPage() {
|
function CoachUtilizationReportPageContent() {
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
@@ -360,3 +362,11 @@ export default function CoachUtilizationReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function CoachUtilizationReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.coachUtilization.view, PERMS.reports.view]}>
|
||||||
|
<CoachUtilizationReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { usePagination } from '@/lib/use-pagination';
|
|||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
|
import { categoricalColor, getChartPalette } from '@/lib/chart-palette';
|
||||||
import { useTheme } from '@/lib/theme-store';
|
import { useTheme } from '@/lib/theme-store';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface StationOption {
|
interface StationOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,7 +103,7 @@ function FinanceReportSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FinanceReportPage() {
|
function FinanceReportPageContent() {
|
||||||
const isDark = useTheme((s) => s.isDark);
|
const isDark = useTheme((s) => s.isDark);
|
||||||
const palette = getChartPalette(isDark);
|
const palette = getChartPalette(isDark);
|
||||||
|
|
||||||
@@ -558,3 +560,11 @@ export default function FinanceReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function FinanceReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.finance.view, PERMS.reports.view]}>
|
||||||
|
<FinanceReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ function esc(s: string) {
|
|||||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReportsPage() {
|
function ReportsPageContent() {
|
||||||
const [dateRange, setDateRange] = useState('30');
|
const [dateRange, setDateRange] = useState('30');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
@@ -709,3 +711,11 @@ export default function ReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ReportsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.overall.view, PERMS.reports.view]}>
|
||||||
|
<ReportsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { formatDateTime } from "@/lib/utils";
|
|||||||
import ActionButton from "@/components/ui/ActionButton";
|
import ActionButton from "@/components/ui/ActionButton";
|
||||||
import Pagination from "@/components/ui/Pagination";
|
import Pagination from "@/components/ui/Pagination";
|
||||||
import { usePagination } from "@/lib/use-pagination";
|
import { usePagination } from "@/lib/use-pagination";
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface ScheduleOption {
|
interface ScheduleOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,7 +72,7 @@ interface PassengerRow {
|
|||||||
|
|
||||||
type Tab = "occupancy" | "list";
|
type Tab = "occupancy" | "list";
|
||||||
|
|
||||||
export default function PassengersReportPage() {
|
function PassengersReportPageContent() {
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState("");
|
||||||
const [tab, setTab] = useState<Tab>("occupancy");
|
const [tab, setTab] = useState<Tab>("occupancy");
|
||||||
const [listSearch, setListSearch] = useState("");
|
const [listSearch, setListSearch] = useState("");
|
||||||
@@ -592,3 +594,11 @@ export default function PassengersReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PassengersReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.passengers.view, PERMS.reports.view]}>
|
||||||
|
<PassengersReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import DatePicker from '@/components/ui/DatePicker';
|
|||||||
import { parse, isValid } from 'date-fns';
|
import { parse, isValid } from 'date-fns';
|
||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -121,7 +123,7 @@ function BalanceBadge({ row }: { row: DiscrepancyRow }) {
|
|||||||
|
|
||||||
type Applied = { from: string; to: string; sortBy: string; search: string };
|
type Applied = { from: string; to: string; sortBy: string; search: string };
|
||||||
|
|
||||||
export default function PaymentDiscrepancyPage() {
|
function PaymentDiscrepancyPageContent() {
|
||||||
const [from, setFrom] = useState('');
|
const [from, setFrom] = useState('');
|
||||||
const [to, setTo] = useState('');
|
const [to, setTo] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
|
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
|
||||||
@@ -479,3 +481,11 @@ export default function PaymentDiscrepancyPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PaymentDiscrepancyPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
|
||||||
|
<PaymentDiscrepancyPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
|
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ function downloadCsv(csv: string, filename: string) {
|
|||||||
|
|
||||||
// ── Discrepancy page ──────────────────────────────────────────────────────────
|
// ── Discrepancy page ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function PaymentsReportPage() {
|
function PaymentsReportPageContent() {
|
||||||
const [scheduleId, setScheduleId] = useState('');
|
const [scheduleId, setScheduleId] = useState('');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [seatClass, setSeatClass] = useState('');
|
const [seatClass, setSeatClass] = useState('');
|
||||||
@@ -355,3 +357,11 @@ export default function PaymentsReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function PaymentsReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.payments.view, PERMS.reports.view]}>
|
||||||
|
<PaymentsReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import ActionButton from "@/components/ui/ActionButton";
|
|||||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||||
import Pagination from "@/components/ui/Pagination";
|
import Pagination from "@/components/ui/Pagination";
|
||||||
import { usePagination } from "@/lib/use-pagination";
|
import { usePagination } from "@/lib/use-pagination";
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
interface ScheduleOption {
|
interface ScheduleOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,7 +58,7 @@ interface SeatStatusReport {
|
|||||||
|
|
||||||
type Tab = "seats" | "blocked";
|
type Tab = "seats" | "blocked";
|
||||||
|
|
||||||
export default function SeatStatusReportPage() {
|
function SeatStatusReportPageContent() {
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState("");
|
||||||
const [tab, setTab] = useState<Tab>("seats");
|
const [tab, setTab] = useState<Tab>("seats");
|
||||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||||
@@ -383,3 +385,11 @@ export default function SeatStatusReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function SeatStatusReportPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={[PERMS.reports.seatStatus.view, PERMS.reports.view]}>
|
||||||
|
<SeatStatusReportPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
|
* Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no
|
||||||
* policy cannot be rescheduled at all. Gated on bookings:view because that is what
|
* policy cannot be rescheduled at all. Gated on reschedule_policies:view (or :manage) — bookings:view no longer
|
||||||
* `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side.
|
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
|
||||||
*/
|
*/
|
||||||
export default function ReschedulePoliciesPage() {
|
export default function ReschedulePoliciesPage() {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard permission={PERMS.bookings.view}>
|
<PermissionGuard permission={[PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage]}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>
|
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
|||||||
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
interface RouteStop {
|
interface RouteStop {
|
||||||
stationId: string;
|
stationId: string;
|
||||||
@@ -193,6 +194,10 @@ function RoutesPageContent() {
|
|||||||
}, [error]);
|
}, [error]);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.routes.create, PERMS.routes.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.routes.edit, PERMS.routes.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.routes.delete);
|
||||||
|
|
||||||
const { data: routes, isLoading: routesLoading } = useQuery({
|
const { data: routes, isLoading: routesLoading } = useQuery({
|
||||||
queryKey: ['routes'],
|
queryKey: ['routes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -445,12 +450,14 @@ function RoutesPageContent() {
|
|||||||
const routeActions = [
|
const routeActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: openEditModal,
|
onClick: openEditModal,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -479,6 +486,9 @@ function RoutesPageContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create routes'}
|
||||||
>
|
>
|
||||||
Add Route
|
Add Route
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { usePagination } from '@/lib/use-pagination';
|
|||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone';
|
||||||
import DateTimePicker from '@/components/ui/DateTimePicker';
|
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
|
||||||
@@ -80,6 +81,13 @@ function SchedulesPageContent() {
|
|||||||
const errorBannerRef = useRef<HTMLDivElement>(null);
|
const errorBannerRef = useRef<HTMLDivElement>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Mirrors the API guards exactly: create/edit/cancel accept the `:manage` umbrella,
|
||||||
|
// delete does not (see PassengerWrite / PassengerDelete in the API).
|
||||||
|
const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.schedules.edit, PERMS.schedules.manage);
|
||||||
|
const canCancel = useWritePermission(PERMS.schedules.cancel, PERMS.schedules.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.schedules.delete);
|
||||||
|
|
||||||
// These modals can scroll internally — a submit failure can land silently off-screen with no
|
// These modals can scroll internally — a submit failure can land silently off-screen with no
|
||||||
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
|
// visible indication anything went wrong. Scroll the banner into view when a new error appears.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -536,12 +544,16 @@ function SchedulesPageContent() {
|
|||||||
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
||||||
const [delayError, setDelayError] = useState<string | null>(null);
|
const [delayError, setDelayError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// `hidden:` was silently doing nothing — DataTable's Action type only reads
|
||||||
|
// `show` — so Report Delay and Cancel were rendering on already-cancelled
|
||||||
|
// schedules. Folded into `show` alongside the permission check.
|
||||||
const scheduleActions = [
|
const scheduleActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
onClick: handleEditClick,
|
onClick: handleEditClick,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
|
show: () => canEdit,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Report Delay',
|
label: 'Report Delay',
|
||||||
@@ -552,20 +564,21 @@ function SchedulesPageContent() {
|
|||||||
},
|
},
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
show: (schedule: Schedule) => canEdit && schedule.status !== 'CANCELLED',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Cancel',
|
label: 'Cancel',
|
||||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: X,
|
icon: X,
|
||||||
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
show: (schedule: Schedule) => canCancel && schedule.status !== 'CANCELLED',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
|
show: () => canDelete,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -582,6 +595,8 @@ function SchedulesPageContent() {
|
|||||||
onClick={handleBulkDelete}
|
onClick={handleBulkDelete}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
loading={bulkDeleteMutation.isPending}
|
loading={bulkDeleteMutation.isPending}
|
||||||
|
disabled={!canDelete}
|
||||||
|
title={canDelete ? undefined : 'You do not have permission to delete schedules'}
|
||||||
>
|
>
|
||||||
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -590,6 +605,8 @@ function SchedulesPageContent() {
|
|||||||
icon={Plus}
|
icon={Plus}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => { setError(null); setShowAddModal(true); }}
|
onClick={() => { setError(null); setShowAddModal(true); }}
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create schedules'}
|
||||||
>
|
>
|
||||||
Add Schedule
|
Add Schedule
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -599,6 +616,8 @@ function SchedulesPageContent() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create schedules'}
|
||||||
>
|
>
|
||||||
Bulk Generate
|
Bulk Generate
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } from '@/lib/api';
|
||||||
import { routesApi } from '@/lib/api/routes';
|
import { routesApi } from '@/lib/api/routes';
|
||||||
import { usePermissionStrict } from '@/lib/use-permission';
|
import { useDeletePermission, usePermissionStrict, useWritePermission } from '@/lib/use-permission';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -63,6 +63,13 @@ function SeatsPageContent() {
|
|||||||
// Strict: being an admin is not enough, the permission has to be granted.
|
// Strict: being an admin is not enough, the permission has to be granted.
|
||||||
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
const canIssueBooking = usePermissionStrict(PERMS.tickets.generate);
|
||||||
|
|
||||||
|
// Blocking takes inventory out of sale, so it is its own grant; removing a seat is
|
||||||
|
// the app's only soft delete and rides on `seats:delete`; maintenance is an edit.
|
||||||
|
const canBlockSeats = useWritePermission(PERMS.seats.block, PERMS.seats.manage);
|
||||||
|
const canEditSeats = useWritePermission(PERMS.seats.edit, PERMS.seats.manage);
|
||||||
|
const canDeleteSeats = useDeletePermission(PERMS.seats.delete);
|
||||||
|
const canCancelBooking = useWritePermission(PERMS.bookings.cancel, PERMS.bookings.manage);
|
||||||
|
|
||||||
const { data: schedulesData } = useQuery({
|
const { data: schedulesData } = useQuery({
|
||||||
queryKey: ['schedules'],
|
queryKey: ['schedules'],
|
||||||
queryFn: () => schedulesApi.getAll(),
|
queryFn: () => schedulesApi.getAll(),
|
||||||
@@ -478,6 +485,10 @@ function SeatsPageContent() {
|
|||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
handleIssueBooking={handleIssueBooking}
|
handleIssueBooking={handleIssueBooking}
|
||||||
canIssueBooking={canIssueBooking}
|
canIssueBooking={canIssueBooking}
|
||||||
|
canBlockSeats={canBlockSeats}
|
||||||
|
canEditSeats={canEditSeats}
|
||||||
|
canDeleteSeats={canDeleteSeats}
|
||||||
|
canCancelBooking={canCancelBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -576,6 +587,10 @@ function SeatsPageContent() {
|
|||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
handleIssueBooking={handleIssueBooking}
|
handleIssueBooking={handleIssueBooking}
|
||||||
canIssueBooking={canIssueBooking}
|
canIssueBooking={canIssueBooking}
|
||||||
|
canBlockSeats={canBlockSeats}
|
||||||
|
canEditSeats={canEditSeats}
|
||||||
|
canDeleteSeats={canDeleteSeats}
|
||||||
|
canCancelBooking={canCancelBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -601,6 +616,10 @@ function SeatsPageContent() {
|
|||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
handleIssueBooking={handleIssueBooking}
|
handleIssueBooking={handleIssueBooking}
|
||||||
canIssueBooking={canIssueBooking}
|
canIssueBooking={canIssueBooking}
|
||||||
|
canBlockSeats={canBlockSeats}
|
||||||
|
canEditSeats={canEditSeats}
|
||||||
|
canDeleteSeats={canDeleteSeats}
|
||||||
|
canCancelBooking={canCancelBooking}
|
||||||
hideNumber={true}
|
hideNumber={true}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -846,7 +865,8 @@ function SeatsPageContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
|
onClick={() => isCoachBlocked(coach) ? handleUnblockCoach(coach) : handleBlockCoach(coach)}
|
||||||
className="ml-2"
|
className="ml-2"
|
||||||
disabled={!isCoachBlocked(coach) && !isCoachUnblocked(coach)}
|
disabled={!canBlockSeats || (!isCoachBlocked(coach) && !isCoachUnblocked(coach))}
|
||||||
|
title={canBlockSeats ? undefined : 'You do not have permission to block seats'}
|
||||||
>
|
>
|
||||||
{isCoachBlocked(coach) ? (
|
{isCoachBlocked(coach) ? (
|
||||||
<>
|
<>
|
||||||
@@ -1326,6 +1346,10 @@ interface SeatIconProps {
|
|||||||
handleClearMaintenance: (seat: any) => void;
|
handleClearMaintenance: (seat: any) => void;
|
||||||
handleIssueBooking: (seat: any, coach: any) => void;
|
handleIssueBooking: (seat: any, coach: any) => void;
|
||||||
canIssueBooking?: boolean;
|
canIssueBooking?: boolean;
|
||||||
|
canBlockSeats?: boolean;
|
||||||
|
canEditSeats?: boolean;
|
||||||
|
canDeleteSeats?: boolean;
|
||||||
|
canCancelBooking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SeatIcon({
|
function SeatIcon({
|
||||||
@@ -1345,6 +1369,10 @@ function SeatIcon({
|
|||||||
handleClearMaintenance,
|
handleClearMaintenance,
|
||||||
handleIssueBooking,
|
handleIssueBooking,
|
||||||
canIssueBooking = false,
|
canIssueBooking = false,
|
||||||
|
canBlockSeats = false,
|
||||||
|
canEditSeats = false,
|
||||||
|
canDeleteSeats = false,
|
||||||
|
canCancelBooking = false,
|
||||||
}: SeatIconProps) {
|
}: SeatIconProps) {
|
||||||
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
const isRemoved = seat.seatNumber && seat.seatNumber.startsWith('-');
|
||||||
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
const seatClassStr = typeof coach?.seatClass === 'string' ? coach.seatClass : (coach?.seatClass?.name || coach?.coachClass || '');
|
||||||
@@ -1362,13 +1390,15 @@ function SeatIcon({
|
|||||||
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
|
<div className="w-11 h-11 rounded border-2 border-dashed border-gray-400 flex items-center justify-center hover:opacity-80 transition-opacity" title="Removed seat">
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||||
<button
|
{canEditSeats && (
|
||||||
onClick={() => handleUndoRemove(seat)}
|
<button
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
onClick={() => handleUndoRemove(seat)}
|
||||||
title="Undo remove"
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
>
|
title="Undo remove"
|
||||||
<RotateCcw className="h-3 w-3 text-gray-700" />
|
>
|
||||||
</button>
|
<RotateCcw className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1376,14 +1406,14 @@ function SeatIcon({
|
|||||||
|
|
||||||
const status = getSeatStatus(seat);
|
const status = getSeatStatus(seat);
|
||||||
const color = getSeatColor(status);
|
const color = getSeatColor(status);
|
||||||
const canBlock = status === 'AVAILABLE';
|
const canBlock = canBlockSeats && status === 'AVAILABLE';
|
||||||
const canUnblock = status === 'BLOCKED';
|
const canUnblock = canBlockSeats && status === 'BLOCKED';
|
||||||
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
||||||
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
||||||
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
||||||
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
const canCancelReservation = canCancelBooking && status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||||
const canMaintenance = false;
|
const canMaintenance = false;
|
||||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
const canClearMaintenance = canEditSeats && status === 'UNDER_MAINTENANCE';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group flex flex-col items-center">
|
<div className="relative group flex flex-col items-center">
|
||||||
@@ -1431,13 +1461,15 @@ function SeatIcon({
|
|||||||
>
|
>
|
||||||
<Lock className="h-3 w-3 text-gray-700" />
|
<Lock className="h-3 w-3 text-gray-700" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{canDeleteSeats && (
|
||||||
onClick={() => handleRemoveSeat(seat)}
|
<button
|
||||||
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
onClick={() => handleRemoveSeat(seat)}
|
||||||
title="Remove seat"
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
>
|
title="Remove seat"
|
||||||
<X className="h-3 w-3 text-gray-700" />
|
>
|
||||||
</button>
|
<X className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canCancelReservation && (
|
{canCancelReservation && (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Pagination from '@/components/ui/Pagination';
|
|||||||
import { usePagination } from '@/lib/use-pagination';
|
import { usePagination } from '@/lib/use-pagination';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
function StationsPageContent() {
|
function StationsPageContent() {
|
||||||
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
const [filters, setFilters] = useState({ search: '', country: '', operational: '' });
|
||||||
@@ -22,6 +23,10 @@ function StationsPageContent() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.stations.create, PERMS.stations.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.stations.edit, PERMS.stations.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.stations.delete);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['stations', filters],
|
queryKey: ['stations', filters],
|
||||||
queryFn: () => stationsApi.getAll(filters),
|
queryFn: () => stationsApi.getAll(filters),
|
||||||
@@ -164,6 +169,7 @@ function StationsPageContent() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (station: any) => {
|
onClick: (station: any) => {
|
||||||
setEditingStation(station);
|
setEditingStation(station);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
@@ -174,6 +180,7 @@ function StationsPageContent() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -194,6 +201,8 @@ function StationsPageContent() {
|
|||||||
setFormError(null);
|
setFormError(null);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create stations'}
|
||||||
>
|
>
|
||||||
Add Station
|
Add Station
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
|||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
|
import { useRouteFareRules, useRouteFareRuleMutations, useSeatClasses } from './hooks';
|
||||||
import type { Route, RouteFareRule, SeatClass } from './types';
|
import type { Route, RouteFareRule, SeatClass } from './types';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
routes: Route[];
|
routes: Route[];
|
||||||
@@ -21,6 +23,12 @@ type OverrideForm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function OverridesTab({ routes }: Props) {
|
export default function OverridesTab({ routes }: Props) {
|
||||||
|
// Fare rules are their own resource on the API (schedule_fares:*), split out of
|
||||||
|
// schedules:manage so editing a timetable and changing a price are separate grants.
|
||||||
|
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
|
||||||
|
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
|
||||||
|
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
|
||||||
|
|
||||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -148,6 +156,9 @@ export default function OverridesTab({ routes }: Props) {
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
|
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
|
||||||
|
|
||||||
|
disabled={!canCreateFare}
|
||||||
|
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
|
||||||
>
|
>
|
||||||
Add Override
|
Add Override
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -165,8 +176,9 @@ export default function OverridesTab({ routes }: Props) {
|
|||||||
{
|
{
|
||||||
label: 'Edit', icon: Edit, variant: 'secondary' as const,
|
label: 'Edit', icon: Edit, variant: 'secondary' as const,
|
||||||
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
|
onClick: (r: RouteFareRule) => setForm({ isOpen: true, rule: r, error: null }),
|
||||||
|
show: () => canEditFare,
|
||||||
},
|
},
|
||||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
|
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDeleteFare },
|
||||||
]}
|
]}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No overrides for this route. Click 'Add Override' to create one."
|
emptyMessage="No overrides for this route. Click 'Add Override' to create one."
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useSegmentFareRules, useSegmentFareMutations, useSeatClasses, useRoutes
|
|||||||
import type { Route, SegmentFareRule, SeatClass } from './types';
|
import type { Route, SegmentFareRule, SeatClass } from './types';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
interface RouteStop { sequence: number; station?: { name: string; code: string } }
|
interface RouteStop { sequence: number; station?: { name: string; code: string } }
|
||||||
|
|
||||||
@@ -50,6 +52,12 @@ interface Props { routes: Route[] }
|
|||||||
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
|
type FormState = { isOpen: boolean; rule: SegmentFareRule | null; error: string | null };
|
||||||
|
|
||||||
export default function SegmentOverridesTab({ routes }: Props) {
|
export default function SegmentOverridesTab({ routes }: Props) {
|
||||||
|
// Fare rules are their own resource on the API (schedule_fares:*), split out of
|
||||||
|
// schedules:manage so editing a timetable and changing a price are separate grants.
|
||||||
|
const canCreateFare = useWritePermission(PERMS.scheduleFares.create, PERMS.scheduleFares.manage);
|
||||||
|
const canEditFare = useWritePermission(PERMS.scheduleFares.edit, PERMS.scheduleFares.manage);
|
||||||
|
const canDeleteFare = useDeletePermission(PERMS.scheduleFares.delete);
|
||||||
|
|
||||||
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
const [selectedRouteId, setSelectedRouteId] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
|
const [form, setForm] = useState<FormState>({ isOpen: false, rule: null, error: null });
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
|
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; id: string | null; name: string; error?: string }>({ isOpen: false, id: null, name: '' });
|
||||||
@@ -164,7 +172,12 @@ export default function SegmentOverridesTab({ routes }: Props) {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<ActionButton icon={Plus} onClick={() => setForm({ isOpen: true, rule: null, error: null })} disabled={!selectedRouteId}>
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={() => setForm({ isOpen: true, rule: null, error: null })}
|
||||||
|
disabled={!selectedRouteId || !canCreateFare}
|
||||||
|
title={canCreateFare ? undefined : 'You do not have permission to create fare overrides'}
|
||||||
|
>
|
||||||
Add Segment Override
|
Add Segment Override
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,8 +189,8 @@ export default function SegmentOverridesTab({ routes }: Props) {
|
|||||||
data={segmentFares}
|
data={segmentFares}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
actions={[
|
actions={[
|
||||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }) },
|
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: (r: SegmentFareRule) => setForm({ isOpen: true, rule: r, error: null }), show: () => canEditFare },
|
||||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }) },
|
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: (r: SegmentFareRule) => setDeleteConfirm({ isOpen: true, id: r.id, name: `${stopLabel(r.originStopSequence)} → ${stopLabel(r.destinationStopSequence)}` }), show: () => canDeleteFare },
|
||||||
]}
|
]}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage="No segment overrides for this route."
|
emptyMessage="No segment overrides for this route."
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ interface Props {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onEdit: (cls: SeatClass) => void;
|
onEdit: (cls: SeatClass) => void;
|
||||||
onDelete: (id: string, cascade: boolean) => void;
|
onDelete: (id: string, cascade: boolean) => void;
|
||||||
|
canEdit?: boolean;
|
||||||
|
canDelete?: boolean;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
deleteError?: string;
|
deleteError?: string;
|
||||||
deleteSuccess?: number;
|
deleteSuccess?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess }: Props) {
|
export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDelete, isDeleting, deleteError, deleteSuccess, canEdit = false, canDelete = false }: Props) {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{
|
const [deleteConfirm, setDeleteConfirm] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -141,8 +143,8 @@ export default function TariffTab({ classes, coachTypes, isLoading, onEdit, onDe
|
|||||||
data={displayed}
|
data={displayed}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
actions={[
|
actions={[
|
||||||
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit },
|
{ label: 'Edit', icon: Edit, variant: 'secondary' as const, onClick: onEdit, show: () => canEdit },
|
||||||
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick },
|
{ label: 'Delete', icon: Trash2, variant: 'danger' as const, onClick: handleDeleteClick, show: () => canDelete },
|
||||||
]}
|
]}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
emptyMessage={search ? 'No tariff rates match your search' : 'No tariff rates found'}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import BaggageTab from './BaggageTab';
|
|||||||
import RateModal from './RateModal';
|
import RateModal from './RateModal';
|
||||||
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
|
import { useSeatClasses, useCoachTypes, useRoutes, useSeatClassMutations, useRouteFareRuleMutations } from './hooks';
|
||||||
import type { SeatClass, TabType } from './types';
|
import type { SeatClass, TabType } from './types';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
export default function TariffRatesPage() {
|
function TariffRatesPageContent() {
|
||||||
const [tab, setTab] = useState<TabType>('tariff');
|
const [tab, setTab] = useState<TabType>('tariff');
|
||||||
const [showRateModal, setShowRateModal] = useState(false);
|
const [showRateModal, setShowRateModal] = useState(false);
|
||||||
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
|
const [editingClass, setEditingClass] = useState<SeatClass | null>(null);
|
||||||
@@ -20,6 +23,10 @@ export default function TariffRatesPage() {
|
|||||||
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
|
const [deleteError, setDeleteError] = useState<string | undefined>(undefined);
|
||||||
const [deleteSuccess, setDeleteSuccess] = useState(0);
|
const [deleteSuccess, setDeleteSuccess] = useState(0);
|
||||||
|
|
||||||
|
const canCreateRate = useWritePermission(PERMS.tariffRates.create, PERMS.tariffRates.manage);
|
||||||
|
const canEditRate = useWritePermission(PERMS.tariffRates.edit, PERMS.tariffRates.manage);
|
||||||
|
const canDeleteRate = useDeletePermission(PERMS.tariffRates.delete);
|
||||||
|
|
||||||
const { allClasses, isLoading } = useSeatClasses();
|
const { allClasses, isLoading } = useSeatClasses();
|
||||||
const { coachTypes } = useCoachTypes();
|
const { coachTypes } = useCoachTypes();
|
||||||
const { routes } = useRoutes();
|
const { routes } = useRoutes();
|
||||||
@@ -71,7 +78,11 @@ export default function TariffRatesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{tab !== 'overrides' && tab !== 'segment-overrides' && (
|
{tab !== 'overrides' && tab !== 'segment-overrides' && (
|
||||||
<ActionButton icon={Plus} onClick={() => {
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
disabled={!canCreateRate}
|
||||||
|
title={canCreateRate ? undefined : 'You do not have permission to create tariff rates'}
|
||||||
|
onClick={() => {
|
||||||
if (tab === 'baggage') {
|
if (tab === 'baggage') {
|
||||||
setShowBaggageModal(true);
|
setShowBaggageModal(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -105,6 +116,8 @@ export default function TariffRatesPage() {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
|
onEdit={cls => { setEditingClass(cls); setPreselectedRouteId(null); setShowRateModal(true); }}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
canEdit={canEditRate}
|
||||||
|
canDelete={canDeleteRate}
|
||||||
isDeleting={seatClassMutations.remove.isPending}
|
isDeleting={seatClassMutations.remove.isPending}
|
||||||
deleteError={deleteError}
|
deleteError={deleteError}
|
||||||
deleteSuccess={deleteSuccess}
|
deleteSuccess={deleteSuccess}
|
||||||
@@ -143,3 +156,11 @@ export default function TariffRatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TariffRatesPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.tariffRates.view}>
|
||||||
|
<TariffRatesPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import { ticketsApi, apiClient, stationsApi, excessBaggageApi, bookingsApi } fro
|
|||||||
import Pagination from '@/components/ui/Pagination';
|
import Pagination from '@/components/ui/Pagination';
|
||||||
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { usePermission, useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
export default function TicketsPage() {
|
function TicketsPageContent() {
|
||||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||||
const [ticketPage, setTicketPage] = useState(1);
|
const [ticketPage, setTicketPage] = useState(1);
|
||||||
const resetTicketPage = () => setTicketPage(1);
|
const resetTicketPage = () => setTicketPage(1);
|
||||||
@@ -69,6 +72,13 @@ export default function TicketsPage() {
|
|||||||
});
|
});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canGenerate = usePermission(PERMS.tickets.generate);
|
||||||
|
const canEditTicket = useWritePermission(PERMS.tickets.edit, PERMS.tickets.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.tickets.delete);
|
||||||
|
// Logging luggage bills the passenger and sends a pay link, so it is its own grant.
|
||||||
|
const canLogBaggage = useWritePermission(PERMS.excessBaggage.charge, PERMS.bookings.manage);
|
||||||
|
const canBoard = useWritePermission(PERMS.tickets.board, PERMS.tickets.manage);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ['tickets', filters, ticketPage],
|
queryKey: ['tickets', filters, ticketPage],
|
||||||
queryFn: () => ticketsApi.getAll({
|
queryFn: () => ticketsApi.getAll({
|
||||||
@@ -554,7 +564,7 @@ export default function TicketsPage() {
|
|||||||
onClick: openExcessModal,
|
onClick: openExcessModal,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Package,
|
icon: Package,
|
||||||
show: (ticket: any) => !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
show: (ticket: any) => canLogBaggage && !!ticket.booking && ['CONFIRMED', 'BOARDED'].includes(ticket.booking?.status ?? ticket.status),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board',
|
label: 'Board',
|
||||||
@@ -562,6 +572,7 @@ export default function TicketsPage() {
|
|||||||
variant: 'primary' as const,
|
variant: 'primary' as const,
|
||||||
icon: LogIn,
|
icon: LogIn,
|
||||||
show: (ticket: any) => {
|
show: (ticket: any) => {
|
||||||
|
if (!canBoard) return false;
|
||||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
if (isRoundTrip) {
|
if (isRoundTrip) {
|
||||||
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
|
const inboundBoarded = !!ticket.booking?.returnBoardedAt;
|
||||||
@@ -595,10 +606,11 @@ export default function TicketsPage() {
|
|||||||
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
|
onClick: (ticket: any) => restoreMutation.mutate(ticket.id),
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: ListCollapse,
|
icon: ListCollapse,
|
||||||
show: (ticket: any) => ticket.status === 'CANCELLED',
|
show: (ticket: any) => canEditTicket && ticket.status === 'CANCELLED',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: handleDeleteClick,
|
onClick: handleDeleteClick,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -621,6 +633,8 @@ export default function TicketsPage() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
loading={generateMissingMutation.isPending}
|
loading={generateMissingMutation.isPending}
|
||||||
onClick={() => generateMissingMutation.mutate()}
|
onClick={() => generateMissingMutation.mutate()}
|
||||||
|
disabled={!canGenerate}
|
||||||
|
title={canGenerate ? undefined : 'You do not have permission to generate tickets'}
|
||||||
>
|
>
|
||||||
Generate Missing
|
Generate Missing
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -760,7 +774,13 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
|
<ActionButton variant="secondary" onClick={() => { setBoardConfirmOpen(false); setTicketToBoard(null); }}>Cancel</ActionButton>
|
||||||
<ActionButton icon={LogIn} loading={boardMutation.isPending} onClick={handleConfirmBoard}>Board and Print</ActionButton>
|
<ActionButton
|
||||||
|
icon={LogIn}
|
||||||
|
loading={boardMutation.isPending}
|
||||||
|
onClick={handleConfirmBoard}
|
||||||
|
disabled={!canBoard}
|
||||||
|
title={canBoard ? undefined : 'You do not have permission to board passengers'}
|
||||||
|
>Board and Print</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -1103,3 +1123,11 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function TicketsPage() {
|
||||||
|
return (
|
||||||
|
<PermissionGuard permission={PERMS.tickets.view}>
|
||||||
|
<TicketsPageContent />
|
||||||
|
</PermissionGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Train as TrainType } from '@/types';
|
|||||||
import { formatDate } from '@/lib/utils';
|
import { formatDate } from '@/lib/utils';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
|
||||||
function TrainsPageContent() {
|
function TrainsPageContent() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
@@ -24,6 +25,10 @@ function TrainsPageContent() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const canCreate = useWritePermission(PERMS.trains.create, PERMS.trains.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.trains.edit, PERMS.trains.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.trains.delete);
|
||||||
|
|
||||||
const { data: trainsData, isLoading: trainsLoading } = useQuery({
|
const { data: trainsData, isLoading: trainsLoading } = useQuery({
|
||||||
queryKey: ['trains'],
|
queryKey: ['trains'],
|
||||||
queryFn: () => fleetApi.getTrains(),
|
queryFn: () => fleetApi.getTrains(),
|
||||||
@@ -174,6 +179,7 @@ function TrainsPageContent() {
|
|||||||
const actions = [
|
const actions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
|
show: () => canEdit,
|
||||||
onClick: (train: TrainType) => {
|
onClick: (train: TrainType) => {
|
||||||
setEditingTrain(train);
|
setEditingTrain(train);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -186,10 +192,11 @@ function TrainsPageContent() {
|
|||||||
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
|
onClick: (train: TrainType) => restoreTrainMutation.mutate(train.id),
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: RotateCcw,
|
icon: RotateCcw,
|
||||||
show: (train: TrainType) => !train.isActive,
|
show: (train: TrainType) => canEdit && !train.isActive,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: handleDelete,
|
onClick: handleDelete,
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -209,6 +216,8 @@ function TrainsPageContent() {
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}}
|
}}
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
|
disabled={!canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create trains'}
|
||||||
>
|
>
|
||||||
Add Train
|
Add Train
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { PERMS } from '@/lib/permissions';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
|
* Master Data → Upgrade Policies. One policy per fare class (coach type); a class with no policy
|
||||||
* can be neither upgraded from nor to. Gated on bookings:view because that is what
|
* can be neither upgraded from nor to. Gated on upgrade_policies:view (or :manage) — bookings:view no longer
|
||||||
* `GET /upgrade/policies` requires; creating, editing and deleting are admin-only server-side.
|
* grants it, so the page needs its own grant. Create/edit/delete each have their own key.
|
||||||
*/
|
*/
|
||||||
export default function UpgradePoliciesPage() {
|
export default function UpgradePoliciesPage() {
|
||||||
return (
|
return (
|
||||||
<PermissionGuard permission={PERMS.bookings.view}>
|
<PermissionGuard permission={[PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage]}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
|
<h1 className="text-3xl font-bold text-foreground">Upgrade Policies</h1>
|
||||||
|
|||||||
@@ -2,17 +2,28 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ShieldOff } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
permission?: string;
|
/**
|
||||||
|
* A single key, or several of which the user needs **any one**. The any-of form
|
||||||
|
* is how a report page accepts either its own key or the `reports:view`
|
||||||
|
* umbrella — mirroring the OR semantics of the API's `PassengerPermissionGuard`.
|
||||||
|
*/
|
||||||
|
permission?: string | string[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps a page to enforce auth + optional permission check.
|
* Wraps a page to enforce auth + optional permission check.
|
||||||
* - Not logged in → redirect to /login
|
* - Not logged in → redirect to /login
|
||||||
* - Missing permission → redirect to /dashboard
|
* - Missing permission → render an explanation (see below)
|
||||||
|
*
|
||||||
|
* This used to redirect a user without the permission to /dashboard. That is a
|
||||||
|
* dead end for anyone lacking `dashboard:view`, since that page renders nothing
|
||||||
|
* either — they got a blank screen with no explanation. Saying what happened is
|
||||||
|
* both kinder and easier to support.
|
||||||
*/
|
*/
|
||||||
export function PermissionGuard({ permission, children }: Props) {
|
export function PermissionGuard({ permission, children }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,17 +31,26 @@ export function PermissionGuard({ permission, children }: Props) {
|
|||||||
const hasPermission = useAuthStore((s) => s.hasPermission);
|
const hasPermission = useAuthStore((s) => s.hasPermission);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) router.replace('/login');
|
||||||
router.replace('/login');
|
}, [isAuthenticated, router]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (permission && !hasPermission(permission)) {
|
|
||||||
router.replace('/dashboard');
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, permission, hasPermission, router]);
|
|
||||||
|
|
||||||
if (!isAuthenticated) return null;
|
if (!isAuthenticated) return null;
|
||||||
if (permission && !hasPermission(permission)) return null;
|
|
||||||
|
const keys = permission === undefined ? [] : Array.isArray(permission) ? permission : [permission];
|
||||||
|
const allowed = keys.length === 0 || keys.some((key) => hasPermission(key));
|
||||||
|
|
||||||
|
if (!allowed) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 px-6 text-center">
|
||||||
|
<ShieldOff className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">You don't have access to this page</h2>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">
|
||||||
|
Your account is missing the permission this page requires. Ask an administrator to grant
|
||||||
|
it if you need access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ interface NavItem {
|
|||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
permission?: string;
|
/** A single key, or several of which the user needs any one. */
|
||||||
|
permission?: string | string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigationSections: { title: string; items: NavItem[] }[] = [
|
const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||||
@@ -92,8 +93,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
||||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
||||||
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view },
|
{ name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: [PERMS.reschedulePolicies.view, PERMS.reschedulePolicies.manage] },
|
||||||
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: PERMS.bookings.view },
|
{ name: 'Upgrade Policies', href: '/upgrade-policies', icon: ArrowUpNarrowWide, permission: [PERMS.upgradePolicies.view, PERMS.upgradePolicies.manage] },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,16 +129,16 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: 'Analytics & Reports',
|
title: 'Analytics & Reports',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: [PERMS.reports.overall.view, PERMS.reports.view] },
|
||||||
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: PERMS.reports.view },
|
{ name: 'Finance', href: '/reports/finance', icon: DollarSign, permission: [PERMS.reports.finance.view, PERMS.reports.view] },
|
||||||
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: PERMS.reports.view },
|
{ name: 'Coaches', href: '/reports/coach-utilization', icon: Grid3x3, permission: [PERMS.reports.coachUtilization.view, PERMS.reports.view] },
|
||||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: [PERMS.reports.seatStatus.view, PERMS.reports.view] },
|
||||||
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: PERMS.reports.view },
|
{ name: 'Blocked Seats', href: '/reports/blocked-seats', icon: Ban, permission: [PERMS.reports.blockedSeats.view, PERMS.reports.view] },
|
||||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: [PERMS.reports.passengers.view, PERMS.reports.view] },
|
||||||
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: PERMS.reports.view },
|
{ name: 'Boarding', href: '/reports/boarding', icon: LogIn, permission: [PERMS.reports.boarding.view, PERMS.reports.view] },
|
||||||
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
|
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
|
||||||
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
|
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: [PERMS.reports.payments.view, PERMS.reports.view] },
|
||||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: [PERMS.reports.catalog.view, PERMS.reports.view] },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -217,9 +218,11 @@ export default function Sidebar() {
|
|||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-6">
|
||||||
{navigationSections.map((section) => {
|
{navigationSections.map((section) => {
|
||||||
const visibleItems = section.items.filter(
|
const visibleItems = section.items.filter((item) => {
|
||||||
(item) => !item.permission || hasPermission(item.permission)
|
if (!item.permission) return true;
|
||||||
);
|
const keys = Array.isArray(item.permission) ? item.permission : [item.permission];
|
||||||
|
return keys.some((key) => hasPermission(key));
|
||||||
|
});
|
||||||
if (visibleItems.length === 0) return null;
|
if (visibleItems.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
import {
|
import {
|
||||||
reschedulePolicyApi,
|
reschedulePolicyApi,
|
||||||
type ReschedulePolicyCoachType,
|
type ReschedulePolicyCoachType,
|
||||||
@@ -35,6 +37,10 @@ const feeLabel = (percent: number, minMinor: number) =>
|
|||||||
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
|
* the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all.
|
||||||
*/
|
*/
|
||||||
export default function ReschedulePolicyManager() {
|
export default function ReschedulePolicyManager() {
|
||||||
|
const canCreate = useWritePermission(PERMS.reschedulePolicies.create, PERMS.reschedulePolicies.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.reschedulePolicies.edit, PERMS.reschedulePolicies.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.reschedulePolicies.delete);
|
||||||
|
|
||||||
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
|
const [rows, setRows] = useState<ReschedulePolicyRow[]>([]);
|
||||||
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
|
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -188,9 +194,10 @@ export default function ReschedulePolicyManager() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (row: ReschedulePolicyRow) => setDeleting(row),
|
onClick: (row: ReschedulePolicyRow) => setDeleting(row),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -206,7 +213,12 @@ export default function ReschedulePolicyManager() {
|
|||||||
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
|
not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no
|
||||||
policy here cannot be rescheduled at all.
|
policy here cannot be rescheduled at all.
|
||||||
</p>
|
</p>
|
||||||
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={openCreate}
|
||||||
|
disabled={available.length === 0 || !canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create reschedule policies'}
|
||||||
|
>
|
||||||
Add Reschedule Policy
|
Add Reschedule Policy
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -353,7 +365,13 @@ export default function ReschedulePolicyManager() {
|
|||||||
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton icon={Save} onClick={submit} loading={saving}>
|
<ActionButton
|
||||||
|
icon={Save}
|
||||||
|
onClick={submit}
|
||||||
|
loading={saving}
|
||||||
|
disabled={editing ? !canEdit : !canCreate}
|
||||||
|
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change reschedule policies'}
|
||||||
|
>
|
||||||
{editing ? 'Update Policy' : 'Create Policy'}
|
{editing ? 'Update Policy' : 'Create Policy'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ interface ActionButtonProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
/**
|
||||||
|
* Native tooltip. CLAUDE.md asks for a disabled control with a visible reason
|
||||||
|
* over a silently hidden one, so permission-gated buttons pass the reason here
|
||||||
|
* alongside `disabled`.
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
@@ -40,6 +46,7 @@ export default function ActionButton({
|
|||||||
loading = false,
|
loading = false,
|
||||||
className,
|
className,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
|
title,
|
||||||
}: ActionButtonProps) {
|
}: ActionButtonProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -63,6 +70,7 @@ export default function ActionButton({
|
|||||||
type={type}
|
type={type}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
title={title}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
|
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all duration-200',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',
|
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[rgb(20,113,76)]',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import DataTable from '@/components/ui/DataTable';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
|
import { useWritePermission, useDeletePermission } from '@/lib/use-permission';
|
||||||
|
import { PERMS } from '@/lib/permissions';
|
||||||
import {
|
import {
|
||||||
upgradePolicyApi,
|
upgradePolicyApi,
|
||||||
type UpgradePolicyCoachType,
|
type UpgradePolicyCoachType,
|
||||||
@@ -38,6 +40,10 @@ const feeLabel = (p: UpgradePolicyRow) =>
|
|||||||
* a dialog, the same shape as Reschedule Policies and Coach Management.
|
* a dialog, the same shape as Reschedule Policies and Coach Management.
|
||||||
*/
|
*/
|
||||||
export default function UpgradePolicyManager() {
|
export default function UpgradePolicyManager() {
|
||||||
|
const canCreate = useWritePermission(PERMS.upgradePolicies.create, PERMS.upgradePolicies.manage);
|
||||||
|
const canEdit = useWritePermission(PERMS.upgradePolicies.edit, PERMS.upgradePolicies.manage);
|
||||||
|
const canDelete = useDeletePermission(PERMS.upgradePolicies.delete);
|
||||||
|
|
||||||
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
|
const [rows, setRows] = useState<UpgradePolicyRow[]>([]);
|
||||||
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
|
const [available, setAvailable] = useState<UpgradePolicyCoachType[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -186,9 +192,10 @@ export default function UpgradePolicyManager() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit },
|
{ label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit, show: () => canEdit },
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
show: () => canDelete,
|
||||||
onClick: (row: UpgradePolicyRow) => setDeleting(row),
|
onClick: (row: UpgradePolicyRow) => setDeleting(row),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
@@ -204,7 +211,12 @@ export default function UpgradePolicyManager() {
|
|||||||
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
|
<em> to</em> and charged per upgraded passenger. A fare class with no policy here can be neither
|
||||||
upgraded from nor to.
|
upgraded from nor to.
|
||||||
</p>
|
</p>
|
||||||
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
|
<ActionButton
|
||||||
|
icon={Plus}
|
||||||
|
onClick={openCreate}
|
||||||
|
disabled={available.length === 0 || !canCreate}
|
||||||
|
title={canCreate ? undefined : 'You do not have permission to create upgrade policies'}
|
||||||
|
>
|
||||||
Add Upgrade Policy
|
Add Upgrade Policy
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +352,13 @@ export default function UpgradePolicyManager() {
|
|||||||
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton icon={Save} onClick={submit} loading={saving}>
|
<ActionButton
|
||||||
|
icon={Save}
|
||||||
|
onClick={submit}
|
||||||
|
loading={saving}
|
||||||
|
disabled={editing ? !canEdit : !canCreate}
|
||||||
|
title={(editing ? canEdit : canCreate) ? undefined : 'You do not have permission to change upgrade policies'}
|
||||||
|
>
|
||||||
{editing ? 'Update Policy' : 'Create Policy'}
|
{editing ? 'Update Policy' : 'Create Policy'}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
|||||||
|
|
||||||
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.';
|
||||||
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
const NETWORK_ERROR_MESSAGE = 'Could not reach the server. Please check your connection and try again.';
|
||||||
|
const FORBIDDEN_MESSAGE =
|
||||||
|
'You do not have permission to do that. Ask an administrator if you need access.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
* Extracts a user-facing message from a failed request. Prefers a real backend-provided message
|
||||||
@@ -55,6 +57,21 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A 403 from this API is always a permission check, and the server's own text
|
||||||
|
// names raw permission keys ("Missing permission. Required one of: …") which
|
||||||
|
// means nothing to a user. Replace it with something actionable, but only when
|
||||||
|
// the server did not send a more specific message of its own.
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
const body = error.response.data;
|
||||||
|
const serverMessage = typeof body?.message === 'string' ? body.message : '';
|
||||||
|
if (!serverMessage || serverMessage.startsWith('Missing permission')) {
|
||||||
|
const friendly = FORBIDDEN_MESSAGE;
|
||||||
|
if (body && typeof body === 'object') body.message = friendly;
|
||||||
|
error.message = friendly;
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
// Normalize in place so every existing `err?.response?.data?.message || err?.message ||
|
||||||
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
// '<fallback>'` call site across the app picks up a friendly message automatically,
|
||||||
// instead of raw axios/network text or an unjoined NestJS validation array.
|
// instead of raw axios/network text or an unjoined NestJS validation array.
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ export const PERMS = {
|
|||||||
edit: 'edr_passenger_app:bookings:edit',
|
edit: 'edr_passenger_app:bookings:edit',
|
||||||
delete: 'edr_passenger_app:bookings:delete',
|
delete: 'edr_passenger_app:bookings:delete',
|
||||||
},
|
},
|
||||||
|
reschedulePolicies: {
|
||||||
|
view: 'edr_passenger_app:reschedule_policies:view',
|
||||||
|
manage: 'edr_passenger_app:reschedule_policies:manage',
|
||||||
|
create: 'edr_passenger_app:reschedule_policies:create',
|
||||||
|
edit: 'edr_passenger_app:reschedule_policies:edit',
|
||||||
|
delete: 'edr_passenger_app:reschedule_policies:delete',
|
||||||
|
},
|
||||||
|
upgradePolicies: {
|
||||||
|
view: 'edr_passenger_app:upgrade_policies:view',
|
||||||
|
manage: 'edr_passenger_app:upgrade_policies:manage',
|
||||||
|
create: 'edr_passenger_app:upgrade_policies:create',
|
||||||
|
edit: 'edr_passenger_app:upgrade_policies:edit',
|
||||||
|
delete: 'edr_passenger_app:upgrade_policies:delete',
|
||||||
|
},
|
||||||
passengers: {
|
passengers: {
|
||||||
view: 'edr_passenger_app:passengers:view',
|
view: 'edr_passenger_app:passengers:view',
|
||||||
manage: 'edr_passenger_app:passengers:manage',
|
manage: 'edr_passenger_app:passengers:manage',
|
||||||
@@ -20,6 +34,7 @@ export const PERMS = {
|
|||||||
view: 'edr_passenger_app:tickets:view',
|
view: 'edr_passenger_app:tickets:view',
|
||||||
manage: 'edr_passenger_app:tickets:manage',
|
manage: 'edr_passenger_app:tickets:manage',
|
||||||
generate: 'edr_passenger_app:tickets:generate',
|
generate: 'edr_passenger_app:tickets:generate',
|
||||||
|
board: 'edr_passenger_app:tickets:board',
|
||||||
create: 'edr_passenger_app:tickets:create',
|
create: 'edr_passenger_app:tickets:create',
|
||||||
edit: 'edr_passenger_app:tickets:edit',
|
edit: 'edr_passenger_app:tickets:edit',
|
||||||
delete: 'edr_passenger_app:tickets:delete',
|
delete: 'edr_passenger_app:tickets:delete',
|
||||||
@@ -114,6 +129,7 @@ export const PERMS = {
|
|||||||
view: 'edr_passenger_app:payments:view',
|
view: 'edr_passenger_app:payments:view',
|
||||||
manage: 'edr_passenger_app:payments:manage',
|
manage: 'edr_passenger_app:payments:manage',
|
||||||
create: 'edr_passenger_app:payments:create',
|
create: 'edr_passenger_app:payments:create',
|
||||||
|
supplementary: 'edr_passenger_app:payments:supplementary',
|
||||||
edit: 'edr_passenger_app:payments:edit',
|
edit: 'edr_passenger_app:payments:edit',
|
||||||
delete: 'edr_passenger_app:payments:delete',
|
delete: 'edr_passenger_app:payments:delete',
|
||||||
// legacy aliases — still honoured by the backend guards
|
// legacy aliases — still honoured by the backend guards
|
||||||
@@ -121,6 +137,9 @@ export const PERMS = {
|
|||||||
refund: 'edr_passenger_app:payments:refund',
|
refund: 'edr_passenger_app:payments:refund',
|
||||||
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
manageMethods: 'edr_passenger_app:payments:manage_methods',
|
||||||
},
|
},
|
||||||
|
excessBaggage: {
|
||||||
|
charge: 'edr_passenger_app:excess_baggage:charge',
|
||||||
|
},
|
||||||
paymentMethods: {
|
paymentMethods: {
|
||||||
view: 'edr_passenger_app:payment_methods:view',
|
view: 'edr_passenger_app:payment_methods:view',
|
||||||
manage: 'edr_passenger_app:payment_methods:manage',
|
manage: 'edr_passenger_app:payment_methods:manage',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuthStore } from './auth-store';
|
import { useAuthStore } from './auth-store';
|
||||||
|
import { PERMS } from './permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the current user has a given permission key.
|
* Returns whether the current user has a given permission key.
|
||||||
@@ -14,6 +15,20 @@ export function usePermission(key: string): boolean {
|
|||||||
return useAuthStore((s) => s.hasPermission(key));
|
return useAuthStore((s) => s.hasPermission(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the user holds **any one** of the keys — the same OR semantics as the
|
||||||
|
* API's `PassengerPermissionGuard`.
|
||||||
|
*
|
||||||
|
* Use it wherever a route accepts a narrow key or a broader one, so the UI agrees
|
||||||
|
* with the API instead of hiding a control the server would have allowed:
|
||||||
|
*
|
||||||
|
* const canCreate = useAnyPermission([PERMS.schedules.create, PERMS.schedules.manage]);
|
||||||
|
* const canSeeFinance = useAnyPermission([PERMS.reports.finance.view, PERMS.reports.view]);
|
||||||
|
*/
|
||||||
|
export function useAnyPermission(keys: string[]): boolean {
|
||||||
|
return useAuthStore((s) => keys.some((key) => s.hasPermission(key)));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
* Same as usePermission but WITHOUT the super-admin / org-admin bypass — the
|
||||||
* permission must be explicitly granted. Use it wherever the API endpoint is
|
* permission must be explicitly granted. Use it wherever the API endpoint is
|
||||||
@@ -25,3 +40,23 @@ export function usePermission(key: string): boolean {
|
|||||||
export function usePermissionStrict(key: string): boolean {
|
export function usePermissionStrict(key: string): boolean {
|
||||||
return useAuthStore((s) => s.hasPermissionStrict(key));
|
return useAuthStore((s) => s.hasPermissionStrict(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UI mirror of the API's `@PassengerWrite(narrow, umbrella)`: the narrow key,
|
||||||
|
* the resource's `:manage` umbrella, or `admin`. Use it for create / edit / domain
|
||||||
|
* actions so a control is shown exactly when the server would accept the call.
|
||||||
|
*
|
||||||
|
* const canCreate = useWritePermission(PERMS.schedules.create, PERMS.schedules.manage);
|
||||||
|
*/
|
||||||
|
export function useWritePermission(narrow: string, umbrella: string): boolean {
|
||||||
|
return useAnyPermission([narrow, umbrella, PERMS.admin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UI mirror of the API's `@PassengerDelete(narrow)`: the narrow `:delete` key
|
||||||
|
* or `admin`. **`:manage` deliberately does not count** — holding `schedules:manage`
|
||||||
|
* does not let you delete a schedule, so the button must not appear either.
|
||||||
|
*/
|
||||||
|
export function useDeletePermission(narrow: string): boolean {
|
||||||
|
return useAnyPermission([narrow, PERMS.admin]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user